diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 561794805..8154a3b9a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1966,6 +1966,12 @@ "relink": { "message": "Relink" }, + "autoUpdateSettingTitle": { + "message": "Auto Update" + }, + "autoUpdateSettingDescription": { + "message": "Automatically check for updates on launch" + }, "autoUpdateNewVersionTitle": { "message": "Session update available" }, diff --git a/app/base_config.d.ts b/app/base_config.d.ts new file mode 100644 index 000000000..3aec7c607 --- /dev/null +++ b/app/base_config.d.ts @@ -0,0 +1,15 @@ +export interface BaseConfig { + set(keyPath: string, value: any): void; + get(keyPath: string): any | undefined; + remove(): void; +} + +interface Options { + allowMalformedOnStartup: boolean; +} + +export function start( + name: string, + targetPath: string, + options: Options +): BaseConfig; diff --git a/app/user_config.d.ts b/app/user_config.d.ts new file mode 100644 index 000000000..4b0a2282c --- /dev/null +++ b/app/user_config.d.ts @@ -0,0 +1,3 @@ +import { BaseConfig } from './base_config'; + +type UserConfig = BaseConfig; diff --git a/js/background.js b/js/background.js index fd9dd2629..59645a013 100644 --- a/js/background.js +++ b/js/background.js @@ -709,12 +709,10 @@ } // lets not allow ANY URLs, lets force it to be local to public chat server - const relativeFileUrl = fileObj.url.replace( - API.serverAPI.baseServerUrl, - '' - ); + const url = new URL(fileObj.url); + // write it to the channel - await API.setChannelAvatar(relativeFileUrl); + await API.setChannelAvatar(url.pathname); } if (await API.setChannelName(groupName)) { @@ -1947,6 +1945,10 @@ }) { return async event => { const { data, confirm } = event; + if (!data) { + window.log.warn('Invalid data passed to createMessageHandler.', event); + return confirm(); + } const messageDescriptor = getMessageDescriptor(data); @@ -1965,38 +1967,42 @@ return handleProfileUpdate({ data, confirm, messageDescriptor }); } - const primaryDeviceKey = window.storage.get('primaryDevicePubKey'); - const allOurDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey( - primaryDeviceKey - ); const descriptorId = await textsecure.MessageReceiver.arrayBufferToString( messageDescriptor.id ); let message; - if ( + const { source } = data; + + // Note: This only works currently because we have a 1 device limit + // When we change that, the check below needs to change too + const ourNumber = textsecure.storage.user.getNumber(); + const primaryDevice = window.storage.get('primaryDevicePubKey'); + const isOurDevice = + source && (source === ourNumber || source === primaryDevice); + const isPublicChatMessage = messageDescriptor.type === 'group' && - descriptorId.match(/^publicChat:/) && - allOurDevices.includes(data.source) - ) { + descriptorId.match(/^publicChat:/); + if (isPublicChatMessage && isOurDevice) { // Public chat messages from ourselves should be outgoing message = await createSentMessage(data); } else { message = await createMessage(data); } + const isDuplicate = await isMessageDuplicate(message); if (isDuplicate) { - // RSS expects duplciates, so squelch log + // RSS expects duplicates, so squelch log if (!descriptorId.match(/^rss:/)) { window.log.warn('Received duplicate message', message.idForLogging()); } - return event.confirm(); + return confirm(); } await ConversationController.getOrCreateAndWait( messageDescriptor.id, messageDescriptor.type ); - return message.handleDataMessage(data.message, event.confirm, { + return message.handleDataMessage(data.message, confirm, { initialLoadComplete, }); }; @@ -2133,35 +2139,35 @@ const message = new Whisper.Message(messageData); - // If we don't return early here, we can get into infinite error loops. So, no - // delivery receipts for sealed sender errors. - + // Send a delivery receipt + // If we don't return early here, we can get into infinite error loops. So, no delivery receipts for sealed sender errors. // Note(LOKI): don't send receipt for FR as we don't have a session yet - if (isError || !data.unidentifiedDeliveryReceived || data.friendRequest) { - return message; - } - - try { + const isGroup = data && data.message && data.message.group; + const shouldSendReceipt = + !isError && + data.unidentifiedDeliveryReceived && + !data.isFriendRequest && + !isGroup; + + // Send the receipt async and hope that it succeeds + if (shouldSendReceipt) { const { wrap, sendOptions } = ConversationController.prepareForSend( data.source ); - const isGroup = data && data.message && data.message.group; - if (!isGroup) { - await wrap( - textsecure.messaging.sendDeliveryReceipt( - data.source, - data.timestamp, - sendOptions - ) + wrap( + textsecure.messaging.sendDeliveryReceipt( + data.source, + data.timestamp, + sendOptions + ) + ).catch(error => { + window.log.error( + `Failed to send delivery receipt to ${data.source} for message ${ + data.timestamp + }:`, + error && error.stack ? error.stack : error ); - } - } catch (error) { - window.log.error( - `Failed to send delivery receipt to ${data.source} for message ${ - data.timestamp - }:`, - error && error.stack ? error.stack : error - ); + }); } return message; diff --git a/js/conversation_controller.js b/js/conversation_controller.js index c6fe5e432..486d297c8 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -218,6 +218,7 @@ } await conversation.destroyMessages(); + await window.Signal.Data.removeConversation(id, { Conversation: Whisper.Conversation, }); diff --git a/js/models/conversations.js b/js/models/conversations.js index b4f68142c..13f7015fa 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -330,9 +330,13 @@ resetMessageSelection() { this.selectedMessages.clear(); this.messageCollection.forEach(m => { - // eslint-disable-next-line no-param-reassign - m.selected = false; - m.trigger('change'); + // on change for ALL messages without real changes is a really costly operation + // -> cause refresh of the whole conversation view even if not a single message was selected + if (m.selected) { + // eslint-disable-next-line no-param-reassign + m.selected = false; + m.trigger('change'); + } }); this.trigger('message-selection-changed'); @@ -2767,7 +2771,9 @@ window.confirmationDialog({ title, message, - resolve: () => ConversationController.deleteContact(this.id), + resolve: () => { + ConversationController.deleteContact(this.id); + }, }); }, diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js index c4945fd76..945ec66a5 100644 --- a/js/modules/loki_app_dot_net_api.js +++ b/js/modules/loki_app_dot_net_api.js @@ -14,6 +14,13 @@ 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 +const FILESERVER_HOSTS = [ + 'file-dev.lokinet.org', + 'file.lokinet.org', + 'file-dev.getsession.org', + 'file.getsession.org', +]; + 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'; @@ -25,6 +32,288 @@ const snodeHttpsAgent = new https.Agent({ rejectUnauthorized: false, }); +const sendToProxy = async ( + srvPubKey, + endpoint, + pFetchOptions, + options = {} +) => { + if (!srvPubKey) { + log.error( + 'loki_app_dot_net: sendToProxy called without a server public key' + ); + return {}; + } + const randSnode = await lokiSnodeAPI.getRandomSnodeAddress(); + const url = `https://${randSnode.ip}:${randSnode.port}/file_proxy`; + + 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, + }; + + // 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'), + }; + } + + // convert our payload to binary buffer + const payloadData = Buffer.from( + dcodeIO.ByteBuffer.wrap(JSON.stringify(payloadObj)).toArrayBuffer() + ); + payloadObj.body = false; // free memory + + // make temporary key for this request/response + const ephemeralKey = libsignal.Curve.generateKeyPair(); + + // mix server pub key with our priv key + const symKey = libsignal.Curve.calculateAgreement( + srvPubKey, // server's pubkey + ephemeralKey.privKey // our privkey + ); + + const ivAndCiphertext = await libloki.crypto.DHEncrypt(symKey, payloadData); + + // convert final buffer to base64 + const cipherText64 = dcodeIO.ByteBuffer.wrap(ivAndCiphertext).toString( + 'base64' + ); + + const ephemeralPubKey64 = dcodeIO.ByteBuffer.wrap( + ephemeralKey.pubKey + ).toString('base64'); + + const finalRequestHeader = { + 'X-Loki-File-Server-Ephemeral-Key': ephemeralPubKey64, + }; + + const firstHopOptions = { + method: 'POST', + // not sure why I can't use anything but json... + // text/plain would be preferred... + body: JSON.stringify({ cipherText64 }), + headers: { + 'Content-Type': 'application/json', + 'X-Loki-File-Server-Target': '/loki/v1/secure_rpc', + 'X-Loki-File-Server-Verb': 'POST', + 'X-Loki-File-Server-Headers': JSON.stringify(finalRequestHeader), + }, + // we are talking to a snode... + agent: snodeHttpsAgent, + }; + // weird this doesn't need NODE_TLS_REJECT_UNAUTHORIZED = '0' + const result = await nodeFetch(url, firstHopOptions); + + const txtResponse = await result.text(); + if (txtResponse.match(/^Service node is not ready: not in any swarm/i)) { + // mark snode bad + const randomPoolRemainingCount = lokiSnodeAPI.markRandomNodeUnreachable( + randSnode + ); + log.warn( + `loki_app_dot_net: Marking random snode bad, internet address ${ + randSnode.ip + }:${ + randSnode.port + }. ${randomPoolRemainingCount} snodes remaining in randomPool` + ); + // retry (hopefully with new snode) + // FIXME: max number of retries... + return sendToProxy(srvPubKey, endpoint, fetchOptions); + } + + let response = {}; + try { + response = JSON.parse(txtResponse); + } catch (e) { + log.warn( + `loki_app_dot_net: sendToProxy Could not parse outer JSON [${txtResponse}]`, + endpoint, + 'on', + url + ); + } + + if (response.meta && response.meta.code === 200) { + // convert base64 in response to binary + const ivAndCiphertextResponse = dcodeIO.ByteBuffer.wrap( + response.data, + 'base64' + ).toArrayBuffer(); + const decrypted = await libloki.crypto.DHDecrypt( + symKey, + ivAndCiphertextResponse + ); + const textDecoder = new TextDecoder(); + const respStr = textDecoder.decode(decrypted); + // replace response + try { + response = options.textResponse ? respStr : JSON.parse(respStr); + } catch (e) { + log.warn( + `loki_app_dot_net: sendToProxy Could not parse inner JSON [${respStr}]`, + endpoint, + 'on', + url + ); + } + } else { + log.warn( + 'loki_app_dot_net: file server secure_rpc gave an non-200 response: ', + response, + ` txtResponse[${txtResponse}]`, + endpoint + ); + } + return { result, txtResponse, response }; +}; + +const serverRequest = async (endpoint, options = {}) => { + const { + params = {}, + method, + rawBody, + objBody, + token, + srvPubKey, + forceFreshToken = false, + } = options; + + const url = new URL(endpoint); + if (params) { + url.search = new URLSearchParams(params); + } + const fetchOptions = {}; + const headers = {}; + try { + if (token) { + headers.Authorization = `Bearer ${token}`; + } + if (method) { + fetchOptions.method = method; + } + if (objBody) { + headers['Content-Type'] = 'application/json'; + fetchOptions.body = JSON.stringify(objBody); + } else if (rawBody) { + fetchOptions.body = rawBody; + } + fetchOptions.headers = headers; + + // domain ends in .loki + if (url.host.match(/\.loki$/i)) { + fetchOptions.agent = snodeHttpsAgent; + } + } catch (e) { + log.info('serverRequest set up error:', e.code, e.message); + return { + err: e, + }; + } + + let response; + let result; + let txtResponse; + let mode = 'nodeFetch'; + try { + const host = url.host.toLowerCase(); + // log.info('host', host, FILESERVER_HOSTS); + if ( + window.lokiFeatureFlags.useSnodeProxy && + FILESERVER_HOSTS.includes(host) + ) { + mode = 'sendToProxy'; + const search = url.search ? `?${url.search}` : ''; + // strip first slash + const endpointWithQS = `${url.pathname}${search}`.replace(/^\//, ''); + // log.info('endpointWithQS', endpointWithQS) + ({ response, txtResponse, result } = await sendToProxy( + srvPubKey, + endpointWithQS, + fetchOptions, + options + )); + } else { + // disable check for .loki + process.env.NODE_TLS_REJECT_UNAUTHORIZED = url.host.match(/\.loki$/i) + ? '0' + : '1'; + 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); + } + } catch (e) { + if (txtResponse) { + log.info( + `serverRequest ${mode} error`, + e.code, + e.message, + `json: ${txtResponse}`, + 'attempting connection to', + url + ); + } else { + log.info( + `serverRequest ${mode} error`, + e.code, + e.message, + 'attempting connection to', + url + ); + } + if (mode === '_sendToProxy') { + // if we can detect, certain types of failures, we can retry... + if (e.code === 'ECONNRESET') { + // retry with counter? + } + } + return { + err: e, + }; + } + // if it's a response style with a meta + if (result.status !== 200) { + if (!forceFreshToken && (!response.meta || response.meta.code === 401)) { + // retry with forcing a fresh token + return serverRequest(endpoint, { + ...options, + forceFreshToken: true, + }); + } + return { + err: 'statusCode', + statusCode: result.status, + response, + }; + } + return { + statusCode: result.status, + response, + }; +}; + // the core ADN class that handles all communication with a specific server class LokiAppDotNetServerAPI { constructor(ourKey, url) { @@ -390,274 +679,25 @@ class LokiAppDotNetServerAPI { json: () => response, }; } - return nodeFetch(urlObj, fetchOptions, options); - } - - async _sendToProxy(endpoint, pFetchOptions, options = {}) { - const randSnode = await lokiSnodeAPI.getRandomSnodeAddress(); - const url = `https://${randSnode.ip}:${randSnode.port}/file_proxy`; - - const fetchOptions = pFetchOptions; // make lint happy - // safety issue with file server, just safer to have this - if (fetchOptions.headers === undefined) { - fetchOptions.headers = {}; + const urlStr = urlObj.toString(); + if (urlStr.match(/\.loki\//)) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } - - const payloadObj = { - body: fetchOptions.body, // might need to b64 if binary... - endpoint, - method: fetchOptions.method, - headers: fetchOptions.headers, - }; - - // from https://github.com/sindresorhus/is-stream/blob/master/index.js - if ( - payloadObj.body && - typeof payloadObj.body === 'object' && - typeof payloadObj.body.pipe === 'function' - ) { - log.info('detected body is a stream'); - 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'), - }; - } - - // convert our payload to binary buffer - const payloadData = Buffer.from( - dcodeIO.ByteBuffer.wrap(JSON.stringify(payloadObj)).toArrayBuffer() - ); - payloadObj.body = false; // free memory - - // make temporary key for this request/response - const ephemeralKey = libsignal.Curve.generateKeyPair(); - - // mix server pub key with our priv key - const symKey = libsignal.Curve.calculateAgreement( - this.pubKey, // server's pubkey - ephemeralKey.privKey // our privkey - ); - - const ivAndCiphertext = await libloki.crypto.DHEncrypt(symKey, payloadData); - - // convert final buffer to base64 - const cipherText64 = dcodeIO.ByteBuffer.wrap(ivAndCiphertext).toString( - 'base64' - ); - - const ephemeralPubKey64 = dcodeIO.ByteBuffer.wrap( - ephemeralKey.pubKey - ).toString('base64'); - - const finalRequestHeader = { - 'X-Loki-File-Server-Ephemeral-Key': ephemeralPubKey64, - }; - - const firstHopOptions = { - method: 'POST', - // not sure why I can't use anything but json... - // text/plain would be preferred... - body: JSON.stringify({ cipherText64 }), - headers: { - 'Content-Type': 'application/json', - 'X-Loki-File-Server-Target': '/loki/v1/secure_rpc', - 'X-Loki-File-Server-Verb': 'POST', - 'X-Loki-File-Server-Headers': JSON.stringify(finalRequestHeader), - }, - // we are talking to a snode... - agent: snodeHttpsAgent, - }; - // weird this doesn't need NODE_TLS_REJECT_UNAUTHORIZED = 0 - const result = await nodeFetch(url, firstHopOptions); - - const txtResponse = await result.text(); - if (txtResponse.match(/^Service node is not ready: not in any swarm/i)) { - // mark snode bad - log.warn( - `Marking random snode bad, internet address ${randSnode.ip}:${ - randSnode.port - }` - ); - lokiSnodeAPI.markRandomNodeUnreachable(randSnode); - // retry (hopefully with new snode) - // FIXME: max number of retries... - return this._sendToProxy(endpoint, fetchOptions); - } - - let response = {}; - try { - response = JSON.parse(txtResponse); - } catch (e) { - log.warn( - `_sendToProxy Could not parse outer JSON [${txtResponse}]`, - endpoint - ); - } - - if (response.meta && response.meta.code === 200) { - // convert base64 in response to binary - const ivAndCiphertextResponse = dcodeIO.ByteBuffer.wrap( - response.data, - 'base64' - ).toArrayBuffer(); - const decrypted = await libloki.crypto.DHDecrypt( - symKey, - ivAndCiphertextResponse - ); - const textDecoder = new TextDecoder(); - const respStr = textDecoder.decode(decrypted); - // replace response - try { - response = options.textResponse ? respStr : JSON.parse(respStr); - } catch (e) { - log.warn( - `_sendToProxy Could not parse inner JSON [${respStr}]`, - endpoint - ); - } - } else { - log.warn( - 'file server secure_rpc gave an non-200 response: ', - response, - ` txtResponse[${txtResponse}]`, - endpoint - ); - } - return { result, txtResponse, response }; + const result = nodeFetch(urlObj, fetchOptions, options); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + return result; } // make a request to the server async serverRequest(endpoint, options = {}) { - const { - params = {}, - method, - rawBody, - objBody, - forceFreshToken = false, - } = options; - - const url = new URL(`${this.baseServerUrl}/${endpoint}`); - if (params) { - url.search = new URLSearchParams(params); - } - const fetchOptions = {}; - const headers = {}; - try { - if (forceFreshToken) { - await this.getOrRefreshServerToken(true); - } - if (this.token) { - headers.Authorization = `Bearer ${this.token}`; - } - if (method) { - fetchOptions.method = method; - } - if (objBody) { - headers['Content-Type'] = 'application/json'; - fetchOptions.body = JSON.stringify(objBody); - } else if (rawBody) { - fetchOptions.body = rawBody; - } - fetchOptions.headers = headers; - - // domain ends in .loki - if (endpoint.match(/\.loki\//)) { - fetchOptions.agent = snodeHttpsAgent; - } - } catch (e) { - log.info('serverRequest set up error:', e.code, e.message); - return { - err: e, - }; - } - - let response; - let result; - let txtResponse; - let mode = 'nodeFetch'; - try { - if ( - window.lokiFeatureFlags.useSnodeProxy && - (this.baseServerUrl === 'https://file-dev.lokinet.org' || - this.baseServerUrl === 'https://file.lokinet.org' || - this.baseServerUrl === 'https://file-dev.getsession.org' || - this.baseServerUrl === 'https://file.getsession.org') - ) { - mode = '_sendToProxy'; - - const endpointWithQS = url - .toString() - .replace(`${this.baseServerUrl}/`, ''); - ({ response, txtResponse, result } = await this._sendToProxy( - endpointWithQS, - fetchOptions, - options - )); - } else { - // disable check for .loki - process.env.NODE_TLS_REJECT_UNAUTHORIZED = endpoint.match(/\.loki\//) - ? 0 - : 1; - result = await nodeFetch(url, fetchOptions); - // always make sure this check is enabled - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 1; - txtResponse = await result.text(); - // hrm cloudflare timeouts (504s) will be html... - response = options.textResponse ? txtResponse : JSON.parse(txtResponse); - } - } catch (e) { - if (txtResponse) { - log.info( - `serverRequest ${mode} error`, - e.code, - e.message, - `json: ${txtResponse}`, - 'attempting connection to', - url - ); - } else { - log.info( - `serverRequest ${mode} error`, - e.code, - e.message, - 'attempting connection to', - url - ); - } - if (mode === '_sendToProxy') { - // if we can detect, certain types of failures, we can retry... - if (e.code === 'ECONNRESET') { - // retry with counter? - } - } - return { - err: e, - }; + if (options.forceFreshToken) { + await this.getOrRefreshServerToken(true); } - // if it's a response style with a meta - if (result.status !== 200) { - if (!forceFreshToken && (!response.meta || response.meta.code === 401)) { - // copy options because lint complains if we modify this directly - const updatedOptions = options; - // force it this time - updatedOptions.forceFreshToken = true; - // retry with updated options - return this.serverRequest(endpoint, updatedOptions); - } - return { - err: 'statusCode', - statusCode: result.status, - response, - }; - } - return { - statusCode: result.status, - response, - }; + return serverRequest(`${this.baseServerUrl}/${endpoint}`, { + ...options, + token: this.token, + srvPubKey: this.pubKey, + }); } async getUserAnnotations(pubKey) { @@ -940,6 +980,8 @@ class LokiPublicChannelAPI { this.deleteLastId = 1; this.timers = {}; this.myPrivateKey = false; + this.messagesPollLock = false; + // can escalated to SQL if it start uses too much memory this.logMop = {}; @@ -1316,7 +1358,6 @@ class LokiPublicChannelAPI { Conversation: Whisper.Conversation, } ); - await this.pollForChannelOnce(); } // get moderation actions @@ -1517,6 +1558,20 @@ class LokiPublicChannelAPI { } async pollOnceForMessages() { + if (this.messagesPollLock) { + // TODO: check if lock is stale + log.warn( + 'pollOnceForModerators locked', + 'on', + this.channelId, + 'at', + this.serverAPI.baseServerUrl + ); + return; + } + // disable locking system for now as it's not quite perfect yet + // this.messagesPollLock = Date.now(); + const params = { include_annotations: 1, include_user_annotations: 1, // to get the home server @@ -1545,6 +1600,7 @@ class LokiPublicChannelAPI { if (res.err) { log.error('pollOnceForMessages receive error', res.err); } + this.messagesPollLock = false; return; } @@ -1700,6 +1756,7 @@ class LokiPublicChannelAPI { // do we really need this? if (!pendingMessages.length) { this.conversation.setLastRetrievedMessage(this.lastGot); + this.messagesPollLock = false; return; } @@ -1749,7 +1806,14 @@ class LokiPublicChannelAPI { ); sendNow.forEach(message => { // send them out now - log.info('emitting primary message', message.serverId); + log.info( + 'emitting primary message', + message.serverId, + 'on', + this.channelId, + 'at', + this.serverAPI.baseServerUrl + ); this.chatAPI.emit('publicMessage', { message, }); @@ -1826,7 +1890,14 @@ class LokiPublicChannelAPI { return; } } - log.info('emitting pending message', message.serverId); + log.info( + 'emitting pending message', + message.serverId, + 'on', + this.channelId, + 'at', + this.serverAPI.baseServerUrl + ); this.chatAPI.emit('publicMessage', { message, }); @@ -1848,6 +1919,7 @@ class LokiPublicChannelAPI { // finally update our position this.conversation.setLastRetrievedMessage(this.lastGot); + this.messagesPollLock = false; } static getPreviewFromAnnotation(annotation) { diff --git a/js/modules/loki_file_server_api.js b/js/modules/loki_file_server_api.js index 1bd5fbc51..5d3dfdde5 100644 --- a/js/modules/loki_file_server_api.js +++ b/js/modules/loki_file_server_api.js @@ -103,8 +103,11 @@ class LokiFileServerInstance { if (!Array.isArray(authorisations)) { return; } + const validAuthorisations = authorisations.filter( + a => a && typeof auth === 'object' + ); await Promise.all( - authorisations.map(async auth => { + validAuthorisations.map(async auth => { // only skip, if in secondary search mode if (isRequest && auth.secondaryDevicePubKey !== user.username) { // this is not the authorization we're looking for diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index 88b02b7af..c3ccf6ae8 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -94,6 +94,7 @@ class LokiMessageAPI { await this.refreshSendingSwarm(pubKey, timestamp); } + // send parameters const params = { pubKey, ttl: ttl.toString(), @@ -104,7 +105,7 @@ class LokiMessageAPI { const promises = []; let completedConnections = 0; for (let i = 0; i < numConnections; i += 1) { - const connectionPromise = this.openSendConnection(params).finally(() => { + const connectionPromise = this._openSendConnection(params).finally(() => { completedConnections += 1; if (completedConnections >= numConnections) { delete this.sendingData[timestamp]; @@ -151,7 +152,9 @@ class LokiMessageAPI { 'Ran out of swarm nodes to query' ); } - log.info(`Successful storage message to ${pubKey}`); + log.info( + `loki_message:::sendMessage - Successfully stored message to ${pubKey}` + ); } async refreshSendingSwarm(pubKey, timestamp) { @@ -162,16 +165,11 @@ class LokiMessageAPI { return true; } - async openSendConnection(params) { + async _openSendConnection(params) { while (!_.isEmpty(this.sendingData[params.timestamp].swarm)) { const snode = this.sendingData[params.timestamp].swarm.shift(); // TODO: Revert back to using snode address instead of IP - const successfulSend = await this.sendToNode( - snode.ip, - snode.port, - snode, - params - ); + const successfulSend = await this._sendToNode(snode, params); if (successfulSend) { return true; } @@ -189,19 +187,19 @@ class LokiMessageAPI { } await this.sendingData[params.timestamp].refreshPromise; // Retry with a fresh list again - return this.openSendConnection(params); + return this._openSendConnection(params); } return false; } - async sendToNode(address, port, targetNode, params) { + async _sendToNode(targetNode, params) { let successiveFailures = 0; while (successiveFailures < MAX_ACCEPTABLE_FAILURES) { await sleepFor(successiveFailures * 500); try { const result = await lokiRpc( - `https://${address}`, - port, + `https://${targetNode.ip}`, + targetNode.port, 'store', params, {}, @@ -211,17 +209,21 @@ class LokiMessageAPI { // Make sure we aren't doing too much PoW const currentDifficulty = window.storage.get('PoWDifficulty', null); - const newDifficulty = result.difficulty; - if (newDifficulty != null && newDifficulty !== currentDifficulty) { - window.storage.put('PoWDifficulty', newDifficulty); + if ( + result && + result.difficulty && + result.difficulty !== currentDifficulty + ) { + window.storage.put('PoWDifficulty', result.difficulty); + // should we return false? } return true; } catch (e) { log.warn( - 'Loki send message error:', + 'loki_message:::_sendToNode - send error:', e.code, e.message, - `from ${address}` + `destination ${targetNode.ip}:${targetNode.port}` ); if (e instanceof textsecure.WrongSwarmError) { const { newSwarm } = e; @@ -238,26 +240,35 @@ class LokiMessageAPI { } else if (e instanceof textsecure.NotFoundError) { // TODO: Handle resolution error } else if (e instanceof textsecure.TimestampError) { - log.warn('Timestamp is invalid'); + log.warn('loki_message:::_sendToNode - Timestamp is invalid'); throw e; } else if (e instanceof textsecure.HTTPError) { // TODO: Handle working connection but error response const body = await e.response.text(); - log.warn('HTTPError body:', body); + log.warn('loki_message:::_sendToNode - HTTPError body:', body); } successiveFailures += 1; } } - log.error(`Failed to send to node: ${address}`); - await lokiSnodeAPI.unreachableNode(params.pubKey, address); + const remainingSwarmSnodes = await lokiSnodeAPI.unreachableNode( + params.pubKey, + targetNode + ); + log.error( + `loki_message:::_sendToNode - Too many successive failures trying to send to node ${ + targetNode.ip + }:${targetNode.port}, ${remainingSwarmSnodes.lengt} remaining swarm nodes` + ); return false; } - async openRetrieveConnection(stopPollingPromise, callback) { + async _openRetrieveConnection(stopPollingPromise, callback) { let stopPollingResult = false; + // When message_receiver restarts from onoffline/ononline events it closes // http-resources, which will then resolve the stopPollingPromise with true. We then // want to cancel these polling connections because new ones will be created + // eslint-disable-next-line more/no-then stopPollingPromise.then(result => { stopPollingResult = result; @@ -274,9 +285,13 @@ class LokiMessageAPI { ) { await sleepFor(successiveFailures * 1000); + // TODO: Revert back to using snode address instead of IP try { - // TODO: Revert back to using snode address instead of IP - let messages = await this.retrieveNextMessages(nodeData.ip, nodeData); + // in general, I think we want exceptions to bubble up + // so the user facing UI can report unhandled errors + // except in this case of living inside http-resource pollServer + // because it just restarts more connections... + let messages = await this._retrieveNextMessages(nodeData); // this only tracks retrieval failures // won't include parsing failures... successiveFailures = 0; @@ -296,7 +311,7 @@ class LokiMessageAPI { callback(messages); } catch (e) { log.warn( - 'Loki retrieve messages error:', + 'loki_message:::_openRetrieveConnection - retrieve error:', e.code, e.message, `on ${nodeData.ip}:${nodeData.port}` @@ -324,40 +339,49 @@ class LokiMessageAPI { } } if (successiveFailures >= MAX_ACCEPTABLE_FAILURES) { + const remainingSwarmSnodes = await lokiSnodeAPI.unreachableNode( + this.ourKey, + nodeData + ); log.warn( - `removing ${nodeData.ip}:${ - nodeData.port - } from our swarm pool. We have ${ + `loki_message:::_openRetrieveConnection - too many successive failures, removing ${ + nodeData.ip + }:${nodeData.port} from our swarm pool. We have ${ Object.keys(this.ourSwarmNodes).length - } usable swarm nodes left` + } usable swarm nodes left (${ + remainingSwarmSnodes.length + } in local db)` ); - await lokiSnodeAPI.unreachableNode(this.ourKey, address); } } // if not stopPollingResult if (_.isEmpty(this.ourSwarmNodes)) { log.error( - 'We no longer have any swarm nodes available to try in pool, closing retrieve connection' + 'loki_message:::_openRetrieveConnection - We no longer have any swarm nodes available to try in pool, closing retrieve connection' ); return false; } return true; } - async retrieveNextMessages(nodeUrl, nodeData) { + // we don't throw or catch here + // mark private (_ prefix) since no error handling is done here... + async _retrieveNextMessages(nodeData) { const params = { pubKey: this.ourKey, lastHash: nodeData.lastHash || '', }; const options = { timeout: 40000, + ourPubKey: this.ourKey, headers: { [LOKI_LONGPOLL_HEADER]: true, }, }; + // let exceptions bubble up const result = await lokiRpc( - `https://${nodeUrl}`, + `https://${nodeData.ip}`, nodeData.port, 'retrieve', params, @@ -365,34 +389,39 @@ class LokiMessageAPI { '/storage_rpc/v1', nodeData ); + return result.messages || []; } + // we don't throw or catch here async startLongPolling(numConnections, stopPolling, callback) { - log.info('startLongPolling for', numConnections, 'connections'); this.ourSwarmNodes = {}; + // load from local DB let nodes = await lokiSnodeAPI.getSwarmNodesForPubKey(this.ourKey); - log.info('swarmNodes', nodes.length, 'for', this.ourKey); - Object.keys(nodes).forEach(j => { - const node = nodes[j]; - log.info(`${j} ${node.ip}:${node.port}`); - }); if (nodes.length < numConnections) { log.warn( - 'Not enough SwarmNodes for our pubkey in local database, getting current list from blockchain' + 'loki_message:::startLongPolling - Not enough SwarmNodes for our pubkey in local database, getting current list from blockchain' ); + // load from blockchain nodes = await lokiSnodeAPI.refreshSwarmNodesForPubKey(this.ourKey); if (nodes.length < numConnections) { log.error( - 'Could not get enough SwarmNodes for our pubkey from blockchain' + 'loki_message:::startLongPolling - Could not get enough SwarmNodes for our pubkey from blockchain' ); } } log.info( - `There are currently ${ - nodes.length - } swarmNodes for pubKey in our local database` + 'loki_message:::startLongPolling - start polling for', + numConnections, + 'connections. We have swarmNodes', + nodes.length, + 'for', + this.ourKey ); + Object.keys(nodes).forEach(j => { + const node = nodes[j]; + log.info(`loki_message: ${j} ${node.ip}:${node.port}`); + }); for (let i = 0; i < nodes.length; i += 1) { const lastHash = await window.Signal.Data.getLastHashBySnode( @@ -406,15 +435,28 @@ class LokiMessageAPI { const promises = []; + let unresolved = numConnections; for (let i = 0; i < numConnections; i += 1) { - promises.push(this.openRetrieveConnection(stopPolling, callback)); + promises.push( + // eslint-disable-next-line more/no-then + this._openRetrieveConnection(stopPolling, callback).then(() => { + unresolved -= 1; + log.info( + 'loki_message:::startLongPolling - There are', + unresolved, + 'open retrieve connections left' + ); + }) + ); } // blocks until numConnections snodes in our swarms have been removed from the list // less than numConnections being active is fine, only need to restart if none per Niels 20/02/13 // or if there is network issues (ENOUTFOUND due to lokinet) await Promise.all(promises); - log.error('All our long poll swarm connections have been removed'); + log.error( + 'loki_message:::startLongPolling - All our long poll swarm connections have been removed' + ); // should we just call ourself again? // no, our caller already handles this... } diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 0dff04c9b..a92a7b31d 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -1,4 +1,4 @@ -/* global log, window, process */ +/* global log, window, process, URL */ const EventEmitter = require('events'); const nodeFetch = require('node-fetch'); const LokiAppDotNetAPI = require('./loki_app_dot_net_api'); @@ -27,16 +27,18 @@ class LokiPublicChatFactoryAPI extends EventEmitter { 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 = serverUrl.match(/\.loki\//) - ? 0 - : 1; + 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; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; // const txt = await res.text(); } catch (e) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 1; + 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; diff --git a/js/modules/loki_rpc.js b/js/modules/loki_rpc.js index 5f29f6c38..cb49b975b 100644 --- a/js/modules/loki_rpc.js +++ b/js/modules/loki_rpc.js @@ -29,7 +29,9 @@ const decryptResponse = async (response, address) => { return {}; }; -const sendToProxy = async (options = {}, targetNode) => { +const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => { const randSnode = await lokiSnodeAPI.getRandomSnodeAddress(); // Don't allow arbitrary URLs, only snodes and loki servers @@ -60,36 +62,101 @@ const sendToProxy = async (options = {}, targetNode) => { 'X-Sender-Public-Key': StringView.arrayBufferToHex(myKeys.pubKey), 'X-Target-Snode-Key': targetNode.pubkey_ed25519, }, + agent: snodeHttpsAgent, }; // we only proxy to snodes... - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; const response = await nodeFetch(url, firstHopOptions); - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 1; + + if (response.status === 401) { + // decom or dereg + // remove + // but which the proxy or the target... + // we got a ton of randomPool nodes, let's just not worry about this one + lokiSnodeAPI.markRandomNodeUnreachable(randSnode); + const randomPoolRemainingCount = lokiSnodeAPI.getRandomPoolLength(); + log.warn( + `lokiRpc sendToProxy`, + `snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${ + targetNode.port + }`, + `snode is decom or dereg: `, + ciphertext, + // `marking random snode bad ${randomPoolRemainingCount} remaining` + `Try #${retryNumber}`, + `removing randSnode leaving ${randomPoolRemainingCount} in the random pool` + ); + // retry, just count it happening 5 times to be the target for now + return sendToProxy(options, targetNode, retryNumber + 1); + } // detect SNode is not ready (not in swarm; not done syncing) - if (response.status === 503) { + if (response.status === 503 || response.status === 500) { const ciphertext = await response.text(); - log.error( - `lokiRpc sendToProxy snode ${randSnode.ip}:${randSnode.port} error`, - ciphertext + // we shouldn't do these, + // it's seems to be not the random node that's always bad + // but the target node + + // we got a ton of randomPool nodes, let's just not worry about this one + lokiSnodeAPI.markRandomNodeUnreachable(randSnode); + const randomPoolRemainingCount = lokiSnodeAPI.getRandomPoolLength(); + log.warn( + `lokiRpc sendToProxy`, + `snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${ + targetNode.port + }`, + `code ${response.status} error`, + ciphertext, + // `marking random snode bad ${randomPoolRemainingCount} remaining` + `Try #${retryNumber}`, + `removing randSnode leaving ${randomPoolRemainingCount} in the random pool` ); // mark as bad for this round (should give it some time and improve success rates) - lokiSnodeAPI.markRandomNodeUnreachable(randSnode); // retry for a new working snode - return sendToProxy(options, targetNode); + const pRetryNumber = retryNumber + 1; + if (pRetryNumber > 5) { + // it's likely a net problem or an actual problem on the target node + // lets mark the target node bad for now + // we'll just rotate it back in if it's a net problem + log.warn(`Failing ${targetNode.ip}:${targetNode.port} after 5 retries`); + if (options.ourPubKey) { + lokiSnodeAPI.unreachableNode(options.ourPubKey, targetNode); + } + return false; + } + // 500 burns through a node too fast, + // let's slow the retry to give it more time to recover + if (response.status === 500) { + await timeoutDelay(5000); + } + return sendToProxy(options, targetNode, pRetryNumber); } + /* + if (response.status === 500) { + // usually when the server returns nothing... + } + */ // FIXME: handle nodeFetch errors/exceptions... if (response.status !== 200) { // let us know we need to create handlers for new unhandled codes - log.warn('lokiRpc sendToProxy fetch non-200 statusCode', response.status); + log.warn( + 'lokiRpc sendToProxy fetch non-200 statusCode', + response.status, + `from snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${ + targetNode.port + }` + ); + return false; } const ciphertext = await response.text(); if (!ciphertext) { // avoid base64 decode failure - log.warn('Server did not return any data for', options); + // usually a 500 but not always + // could it be a timeout? + log.warn('Server did not return any data for', options, targetNode); + return false; } let plaintext; @@ -112,7 +179,9 @@ const sendToProxy = async (options = {}, targetNode) => { 'lokiRpc sendToProxy decode error', e.code, e.message, - `from ${randSnode.ip}:${randSnode.port} ciphertext:`, + `from ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${ + targetNode.port + } ciphertext:`, ciphertext ); if (ciphertextBuffer) { @@ -138,6 +207,15 @@ const sendToProxy = async (options = {}, targetNode) => { } return false; }; + if (retryNumber) { + log.info( + `lokiRpc sendToProxy request succeeded,`, + `snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${ + targetNode.port + }`, + `on retry #${retryNumber}` + ); + } return jsonRes; } catch (e) { log.error( @@ -182,22 +260,24 @@ const lokiFetch = async (url, options = {}, targetNode = null) => { timeout, method, }; - if (url.match(/https:\/\//)) { - fetchOptions.agent = snodeHttpsAgent; - } try { if (window.lokiFeatureFlags.useSnodeProxy && targetNode) { const result = await sendToProxy(fetchOptions, targetNode); - return result ? result.json() : false; + // if not result, maybe we should throw?? + return result ? result.json() : {}; } if (url.match(/https:\/\//)) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; + // import that this does not get set in sendToProxy fetchOptions + fetchOptions.agent = snodeHttpsAgent; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } else { + log.info('lokiRpc http communication', url); } const response = await nodeFetch(url, fetchOptions); // restore TLS checking - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 1; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; let result; // Wrong swarm diff --git a/js/modules/loki_snode_api.js b/js/modules/loki_snode_api.js index 7d163a215..46b18b9ee 100644 --- a/js/modules/loki_snode_api.js +++ b/js/modules/loki_snode_api.js @@ -1,10 +1,12 @@ /* eslint-disable class-methods-use-this */ -/* global window, ConversationController, _, log */ +/* global window, ConversationController, _, log, clearTimeout */ const is = require('@sindresorhus/is'); const { lokiRpc } = require('./loki_rpc'); -const RANDOM_SNODES_TO_USE = 3; +const RANDOM_SNODES_TO_USE_FOR_PUBKEY_SWARM = 3; +const RANDOM_SNODES_POOL_SIZE = 1024; +const SEED_NODE_RETRIES = 3; class LokiSnodeAPI { constructor({ serverUrl, localUrl }) { @@ -15,13 +17,14 @@ class LokiSnodeAPI { this.localUrl = localUrl; // localhost.loki this.randomSnodePool = []; this.swarmsPendingReplenish = {}; + this.refreshRandomPoolPromise = false; } async getRandomSnodeAddress() { /* resolve random snode */ if (this.randomSnodePool.length === 0) { // allow exceptions to pass through upwards - await this.initialiseRandomPool(); + await this.refreshRandomPool(); } if (this.randomSnodePool.length === 0) { throw new window.textsecure.SeedNodeError('Invalid seed node response'); @@ -31,74 +34,150 @@ class LokiSnodeAPI { ]; } - async initialiseRandomPool( - seedNodes = [...window.seedNodeList], - consecutiveErrors = 0 - ) { - const params = { - limit: 20, - active_only: true, - fields: { - public_ip: true, - storage_port: true, - pubkey_x25519: true, - pubkey_ed25519: true, - }, - }; - const seedNode = seedNodes.splice( - Math.floor(Math.random() * seedNodes.length), - 1 - )[0]; - let snodes = []; + async refreshRandomPool(seedNodes = [...window.seedNodeList]) { + // if currently not in progress + if (this.refreshRandomPoolPromise === false) { + // set lock + this.refreshRandomPoolPromise = new Promise(async (resolve, reject) => { + let timeoutTimer = null; + // private retry container + const trySeedNode = async (consecutiveErrors = 0) => { + const params = { + limit: RANDOM_SNODES_POOL_SIZE, + active_only: true, + fields: { + public_ip: true, + storage_port: true, + pubkey_x25519: true, + pubkey_ed25519: true, + }, + }; + const seedNode = seedNodes.splice( + Math.floor(Math.random() * seedNodes.length), + 1 + )[0]; + let snodes = []; + try { + log.info( + 'loki_snodes:::refreshRandomPoolPromise - Refreshing random snode pool' + ); + const response = await lokiRpc( + `http://${seedNode.ip}`, + seedNode.port, + 'get_n_service_nodes', + params, + {}, // Options + '/json_rpc' // Seed request endpoint + ); + // Filter 0.0.0.0 nodes which haven't submitted uptime proofs + snodes = response.result.service_node_states.filter( + snode => snode.public_ip !== '0.0.0.0' + ); + this.randomSnodePool = snodes.map(snode => ({ + ip: snode.public_ip, + port: snode.storage_port, + pubkey_x25519: snode.pubkey_x25519, + pubkey_ed25519: snode.pubkey_ed25519, + })); + log.info( + 'loki_snodes:::refreshRandomPoolPromise - Refreshed random snode pool with', + this.randomSnodePool.length, + 'snodes' + ); + // clear lock + this.refreshRandomPoolPromise = null; + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + resolve(); + } catch (e) { + log.warn( + 'loki_snodes:::refreshRandomPoolPromise - error', + e.code, + e.message + ); + if (consecutiveErrors < SEED_NODE_RETRIES) { + // retry after a possible delay + setTimeout(() => { + log.info( + 'loki_snodes:::refreshRandomPoolPromise - Retrying initialising random snode pool, try #', + consecutiveErrors + ); + trySeedNode(consecutiveErrors + 1); + }, consecutiveErrors * consecutiveErrors * 5000); + } else { + log.error( + 'loki_snodes:::refreshRandomPoolPromise - Giving up trying to contact seed node' + ); + if (snodes.length === 0) { + this.refreshRandomPoolPromise = null; // clear lock + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + reject(); + } + } + } + }; + const delay = (SEED_NODE_RETRIES + 1) * (SEED_NODE_RETRIES + 1) * 5000; + timeoutTimer = setTimeout(() => { + log.warn( + 'loki_snodes:::refreshRandomPoolPromise - TIMEDOUT after', + delay, + 's' + ); + reject(); + }, delay); + trySeedNode(); + }); + } try { - const response = await lokiRpc( - `http://${seedNode.ip}`, - seedNode.port, - 'get_n_service_nodes', - params, - {}, // Options - '/json_rpc' // Seed request endpoint - ); - // Filter 0.0.0.0 nodes which haven't submitted uptime proofs - snodes = response.result.service_node_states.filter( - snode => snode.public_ip !== '0.0.0.0' - ); - this.randomSnodePool = snodes.map(snode => ({ - ip: snode.public_ip, - port: snode.storage_port, - pubkey_x25519: snode.pubkey_x25519, - pubkey_ed25519: snode.pubkey_ed25519, - })); + await this.refreshRandomPoolPromise; } catch (e) { - log.warn('initialiseRandomPool error', e.code, e.message); - if (consecutiveErrors < 3) { - // retry after a possible delay - setTimeout(() => { - log.info( - 'Retrying initialising random snode pool, try #', - consecutiveErrors - ); - this.initialiseRandomPool(seedNodes, consecutiveErrors + 1); - }, consecutiveErrors * consecutiveErrors * 5000); - } else { - log.error('Giving up trying to contact seed node'); - if (snodes.length === 0) { - throw new window.textsecure.SeedNodeError( - 'Failed to contact seed node' - ); - } - } + // we will throw for each time initialiseRandomPool has been called in parallel + log.error( + 'loki_snodes:::refreshRandomPoolPromise - error', + e.code, + e.message + ); + throw new window.textsecure.SeedNodeError('Failed to contact seed node'); } + log.info('loki_snodes:::refreshRandomPoolPromise - RESOLVED'); } - // nodeUrl is like 9hrje1bymy7hu6nmtjme9idyu3rm8gr3mkstakjyuw1997t7w4ny.snode - async unreachableNode(pubKey, nodeUrl) { + // unreachableNode.url is like 9hrje1bymy7hu6nmtjme9idyu3rm8gr3mkstakjyuw1997t7w4ny.snode + async unreachableNode(pubKey, unreachableNode) { const conversation = ConversationController.get(pubKey); const swarmNodes = [...conversation.get('swarmNodes')]; - const filteredNodes = swarmNodes.filter( - node => node.address !== nodeUrl && node.ip !== nodeUrl - ); + if (typeof unreachableNode === 'string') { + log.warn( + 'loki_snodes:::unreachableNode - String passed as unreachableNode to unreachableNode' + ); + return swarmNodes; + } + let found = false; + const filteredNodes = swarmNodes.filter(node => { + // keep all but thisNode + const thisNode = + node.address === unreachableNode.address && + node.ip === unreachableNode.ip && + node.port === unreachableNode.port; + if (thisNode) { + found = true; + } + return !thisNode; + }); + if (!found) { + log.warn( + `loki_snodes:::unreachableNode - snode ${unreachableNode.ip}:${ + unreachableNode.port + } has already been marked as bad` + ); + } await conversation.updateSwarmNodes(filteredNodes); + return filteredNodes; } markRandomNodeUnreachable(snode) { @@ -108,6 +187,10 @@ class LokiSnodeAPI { ); } + getRandomPoolLength() { + return this.randomSnodePool.length; + } + async updateLastHash(snode, hash, expiresAt) { await window.Signal.Data.updateLastHash({ snode, hash, expiresAt }); } @@ -150,7 +233,11 @@ class LokiSnodeAPI { try { newSwarmNodes = await this.getSwarmNodes(pubKey); } catch (e) { - log.error('getFreshSwarmNodes error', e.code, e.message); + log.error( + 'loki_snodes:::getFreshSwarmNodes - error', + e.code, + e.message + ); // TODO: Handle these errors sensibly newSwarmNodes = []; } @@ -177,30 +264,43 @@ class LokiSnodeAPI { ); if (!result) { log.warn( - `getSnodesForPubkey lokiRpc on ${snode.ip}:${ + `loki_snode:::getSnodesForPubkey - lokiRpc on ${snode.ip}:${ snode.port } returned falsish value`, result ); return []; } + 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`, + result + ); + return []; + } const snodes = result.snodes.filter(tSnode => tSnode.ip !== '0.0.0.0'); return snodes; } catch (e) { + this.markRandomNodeUnreachable(snode); + const randomPoolRemainingCount = this.getRandomPoolLength(); log.error( - 'getSnodesForPubkey error', + 'loki_snodes:::getSnodesForPubkey - error', e.code, e.message, - `for ${snode.ip}:${snode.port}` + `for ${snode.ip}:${ + snode.port + }. ${randomPoolRemainingCount} snodes remaining in randomPool` ); - this.markRandomNodeUnreachable(snode); return []; } } async getSwarmNodes(pubKey) { const snodes = []; - const questions = [...Array(RANDOM_SNODES_TO_USE).keys()]; + const questions = [...Array(RANDOM_SNODES_TO_USE_FOR_PUBKEY_SWARM).keys()]; await Promise.all( questions.map(async () => { // allow exceptions to pass through upwards diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index 631433d81..a50a9f86f 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -223,7 +223,7 @@ if (item) { return item.value; } - + window.log.error('Could not load identityKey from SignalData'); return undefined; }, async getLocalRegistrationId() { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 93ce33d67..e32b56742 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -2408,7 +2408,7 @@ let direction = $input.selectionDirection; if (event.shiftKey) { - if (direction === 'none') { + if (direction === 'none' || direction === 'forward') { if (isLeft) { direction = 'backward'; } else { diff --git a/libloki/api.js b/libloki/api.js index b18746ef8..6456e2688 100644 --- a/libloki/api.js +++ b/libloki/api.js @@ -110,7 +110,7 @@ async function createContactSyncProtoMessage(conversations) { // Extract required contacts information out of conversations const sessionContacts = conversations.filter( - c => c.isPrivate() && !c.isSecondaryDevice() + c => c.isPrivate() && !c.isSecondaryDevice() && c.isFriend() ); if (sessionContacts.length === 0) { diff --git a/libloki/storage.js b/libloki/storage.js index 0e4cd3dda..ff52e52c5 100644 --- a/libloki/storage.js +++ b/libloki/storage.js @@ -147,7 +147,9 @@ ({ authorisations } = primaryDeviceMapping); } } - return authorisations || []; + + // filter out any invalid authorisations + return authorisations.filter(a => a && typeof a === 'object') || []; } // if the device is a secondary device, @@ -168,6 +170,10 @@ } async function savePairingAuthorisation(authorisation) { + if (!authorisation) { + return; + } + // Ensure that we have a conversation for all the devices const conversation = await ConversationController.getOrCreateAndWait( authorisation.secondaryDevicePubKey, diff --git a/libtextsecure/http-resources.js b/libtextsecure/http-resources.js index 04de289cd..02d647ef5 100644 --- a/libtextsecure/http-resources.js +++ b/libtextsecure/http-resources.js @@ -84,6 +84,8 @@ }; this.pollServer = async () => { + // bg.connect calls mr connect after storage system is ready + window.log.info('http-resource pollServer start'); // This blocking call will return only when all attempts // at reaching snodes are exhausted or a DNS error occured try { @@ -93,25 +95,30 @@ messages => { connected = true; messages.forEach(message => { - const { data } = message; - this.handleMessage(data); + this.handleMessage(message.data); }); } ); } catch (e) { // we'll try again anyway - window.log.error('http-resource pollServer error', e.code, e.message); + window.log.error( + 'http-resource pollServer error', + e.code, + e.message, + e.stack + ); } + connected = false; if (this.calledStop) { + // don't restart return; } - connected = false; // Exhausted all our snodes urls, trying again later from scratch setTimeout(() => { window.log.info( - `Exhausted all our snodes urls, trying again in ${EXHAUSTED_SNODES_RETRY_DELAY / + `http-resource: Exhausted all our snodes urls, trying again in ${EXHAUSTED_SNODES_RETRY_DELAY / 1000}s from scratch` ); this.pollServer(); diff --git a/main.js b/main.js index c64ac5752..e0a54cf45 100644 --- a/main.js +++ b/main.js @@ -429,7 +429,7 @@ async function readyForUpdates() { // Second, start checking for app updates try { - await updater.start(getMainWindow, locale.messages, logger); + await updater.start(getMainWindow, userConfig, locale.messages, logger); } catch (error) { const log = logger || console; log.error( @@ -1092,6 +1092,24 @@ ipc.on('set-media-permissions', (event, value) => { } }); +// Loki - Auto updating +ipc.on('get-auto-update-setting', event => { + const configValue = userConfig.get('autoUpdate'); + // eslint-disable-next-line no-param-reassign + event.returnValue = typeof configValue !== 'boolean' ? true : configValue; +}); + +ipc.on('set-auto-update-setting', (event, enabled) => { + userConfig.set('autoUpdate', !!enabled); + + if (enabled) { + readyForUpdates(); + } else { + updater.stop(); + isReadyForUpdates = false; + } +}); + function getDataFromMainWindow(name, callback) { ipc.once(`get-success-${name}`, (_event, error, value) => callback(error, value) diff --git a/preload.js b/preload.js index 8317ab2df..b2b95878e 100644 --- a/preload.js +++ b/preload.js @@ -227,8 +227,11 @@ window.getSettingValue = (settingID, comparisonValue = null) => { // Eg. window.getSettingValue('theme', 'light') // returns 'false' when the value is 'dark'. + // We need to get specific settings from the main process if (settingID === 'media-permissions') { return window.getMediaPermissions(); + } else if (settingID === 'auto-update') { + return window.getAutoUpdateEnabled(); } const settingVal = window.storage.get(settingID); @@ -236,6 +239,12 @@ window.getSettingValue = (settingID, comparisonValue = null) => { }; window.setSettingValue = (settingID, value) => { + // For auto updating we need to pass the value to the main process + if (settingID === 'auto-update') { + window.setAutoUpdateEnabled(value); + return; + } + window.storage.put(settingID, value); // FIXME - This should be called in the settings object in @@ -252,6 +261,11 @@ window.getMessageTTL = () => window.storage.get('message-ttl', 24); window.getMediaPermissions = () => ipc.sendSync('get-media-permissions'); window.setMediaPermissions = value => ipc.send('set-media-permissions', !!value); +// Auto update setting +window.getAutoUpdateEnabled = () => ipc.sendSync('get-auto-update-setting'); +window.setAutoUpdateEnabled = value => + ipc.send('set-auto-update-setting', !!value); + ipc.on('get-ready-for-shutdown', async () => { const { shutdown } = window.Events || {}; if (!shutdown) { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index ed30fc502..3af867ec2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -653,11 +653,14 @@ // Module: Expire Timer +.module-expire-timer-margin { + margin-left: 6px; +} + .module-expire-timer { width: 12px; height: 12px; display: inline-block; - margin-left: 6px; margin-bottom: 2px; @include color-svg('../images/timer-60.svg', $color-gray-60); } diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index da16e5c20..b0b42aaf9 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -1407,6 +1407,7 @@ label { resize: none; overflow: hidden; user-select: all; + overflow-y: auto; } input { @@ -1496,7 +1497,7 @@ input { border-radius: 50%; opacity: 1; background-color: $session-shade-2; - box-shadow: 0px 0px $session-font-sm 0px rgba($session-color-white, 0.2); + box-shadow: 0px 0px 10px 0px rgba($session-color-white, 0.1); svg path { transition: $session-transition-duration; diff --git a/stylesheets/_session_conversation.scss b/stylesheets/_session_conversation.scss index dcc523d5c..296d101ea 100644 --- a/stylesheets/_session_conversation.scss +++ b/stylesheets/_session_conversation.scss @@ -295,7 +295,7 @@ $composition-container-height: 60px; position: absolute; left: 0px; font-size: 0px; - height: 3px; + height: 1px; background-color: $session-color-green; diff --git a/ts/components/conversation/ExpireTimer.tsx b/ts/components/conversation/ExpireTimer.tsx index cd66055fd..929f386c9 100644 --- a/ts/components/conversation/ExpireTimer.tsx +++ b/ts/components/conversation/ExpireTimer.tsx @@ -47,11 +47,27 @@ export class ExpireTimer extends React.Component { } = this.props; const bucket = getTimerBucket(expirationTimestamp, expirationLength); + let timeLeft = Math.round((expirationTimestamp - Date.now()) / 1000); + timeLeft = timeLeft >= 0 ? timeLeft : 0; + if (timeLeft <= 60) { + return ( + + {timeLeft} + + ); + } return (
{ } public renderMetadataBadges() { - const { direction, isPublic, senderIsModerator } = this.props; + const { direction, isPublic, senderIsModerator, id } = this.props; const badges = [isPublic && 'Public', senderIsModerator && 'Mod']; @@ -225,7 +225,7 @@ export class Message extends React.PureComponent { } return ( - <> +
 •  @@ -240,7 +240,7 @@ export class Message extends React.PureComponent { > {badgeText} - +
); }) .filter(i => !!i); @@ -1068,9 +1068,13 @@ export class Message extends React.PureComponent { // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. - const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`); - const rightClickTriggerId = `${authorPhoneNumber}-ctx-${timestamp}`; - + // The Date.now() is a workaround to be sure a single triggerID with this id exists + const triggerId = id + ? String(`${id}-${Date.now()}`) + : String(`${authorPhoneNumber}-${timestamp}`); + const rightClickTriggerId = id + ? String(`${id}-ctx-${Date.now()}`) + : String(`${authorPhoneNumber}-ctx-${timestamp}`); if (expired) { return null; } @@ -1119,12 +1123,23 @@ export class Message extends React.PureComponent { expiring ? 'module-message--expired' : null )} role="button" - onClick={() => { + onClick={event => { const selection = window.getSelection(); + // Text is being selected if (selection && selection.type === 'Range') { return; } - id && this.props.onSelectMessage(id); + + // User clicked on message body + const target = event.target as HTMLDivElement; + if (target.className === 'text-selectable') { + return; + } + + if (id){ + this.props.onSelectMessage(id); + } + }} > {this.renderError(isIncoming)} diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx index de9119337..e86ed1ef0 100644 --- a/ts/components/session/SessionClosableOverlay.tsx +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -176,9 +176,8 @@ export class SessionClosableOverlay extends React.Component { value={this.state.groupName} maxLength={window.CONSTANTS.MAX_GROUPNAME_LENGTH} onChange={this.onGroupNameChanged} + onPressEnter={() => onButtonClick(this.state.groupName)} /> - - {/* */}
) : ( { private handleKeyDown(e: any) { const { editable, onPressEnter } = this.props; - if (editable && e.keyCode === 13) { + if (editable && e.key === 'Enter') { e.preventDefault(); // tslint:disable-next-line: no-unused-expression onPressEnter && onPressEnter(); diff --git a/ts/components/session/SessionProgress.tsx b/ts/components/session/SessionProgress.tsx index 6e7a61a52..fc93febe8 100644 --- a/ts/components/session/SessionProgress.tsx +++ b/ts/components/session/SessionProgress.tsx @@ -9,17 +9,19 @@ interface Props { prevValue?: number; sendStatus: -1 | 0 | 1 | 2; visible: boolean; - fadeOnComplete: boolean; + showOnComplete: boolean; + + resetProgress: any; } interface State { + show: boolean; visible: boolean; - startFade: boolean; } export class SessionProgress extends React.PureComponent { public static defaultProps = { - fadeOnComplete: true, + showOnComplete: true, }; constructor(props: any) { @@ -28,14 +30,21 @@ export class SessionProgress extends React.PureComponent { const { visible } = this.props; this.state = { + show: true, visible, - startFade: false, }; + + this.onComplete = this.onComplete.bind(this); + } + + public componentWillReceiveProps() { + // Reset show for each reset + this.setState({show: true}); } public render() { - const { startFade } = this.state; const { value, prevValue, sendStatus } = this.props; + const { show } = this.state; // Duration will be the decimal (in seconds) of // the percentage differnce, else 0.25s; @@ -53,44 +62,54 @@ export class SessionProgress extends React.PureComponent { const backgroundColor = sendStatus === -1 ? failureColor : successColor; const shiftDurationMs = this.getShiftDuration(this.props.value, prevValue) * 1000; - const fadeDurationMs = 500; - const fadeOffsetMs = shiftDurationMs + 500; + const showDurationMs = 500; + const showOffsetMs = shiftDurationMs + 500; + + const willComplete = value >= 100; + if (willComplete && !show){ + setTimeout( + this.onComplete, + shiftDurationMs, + ); + } const style = { 'background-color': backgroundColor, 'transform': `translateX(-${100 - value}%)`, - 'transition-property': 'transform, opacity', - 'transition-duration': `${shiftDurationMs}ms, ${fadeDurationMs}ms`, - 'transition-delay': `0ms, ${fadeOffsetMs}ms`, - 'transition-timing-funtion':'cubic-bezier(0.25, 0.46, 0.45, 0.94), linear', - } - - if (value >= 100) { - this.onComplete(); + 'transition-property': 'transform', + // 'transition-property': 'transform, opacity', + 'transition-duration': `${shiftDurationMs}ms`, + // 'transition-duration': `${shiftDurationMs}ms, ${showDurationMs}ms`, + 'transition-delay': `0ms`, + // 'transition-delay': `0ms, ${showOffsetMs}ms`, + 'transition-timing-funtion':'cubic-bezier(0.25, 0.46, 0.45, 0.94)', + //'transition-timing-funtion':'cubic-bezier(0.25, 0.46, 0.45, 0.94), linear', } - + return (
-
-   -
+ { show && ( +
+   +
+ )}
); } - - public onComplete() { - const { fadeOnComplete } = this.props; - - // Fade - if (fadeOnComplete) { - this.setState({ - startFade: true, - }); - + + public onComplete(){ + if (!this.state.show) { + return; } + + console.log(`[sending] ONCOMPLETE`); + this.setState({show: false}, () => { + setTimeout(this.props.resetProgress, 2000); + }); + } private getShiftDuration(value: number, prevValue?: number) { diff --git a/ts/components/session/SessionScrollButton.tsx b/ts/components/session/SessionScrollButton.tsx index b0dba6541..f563f025b 100644 --- a/ts/components/session/SessionScrollButton.tsx +++ b/ts/components/session/SessionScrollButton.tsx @@ -4,7 +4,7 @@ import { SessionIconButton, SessionIconSize, SessionIconType } from './icon'; interface Props { onClick?: any; - display?: boolean; + show?: boolean; } export class SessionScrollButton extends React.PureComponent { @@ -15,7 +15,7 @@ export class SessionScrollButton extends React.PureComponent { public render() { return ( <> - {this.props.display && ( + {this.props.show && (
{ public render() { const { searchString } = this.props; + const triggerId = 'session-search-input-context'; return ( -
- - this.props.onChange(e.target.value)} - onKeyDown={this.handleKeyDown} - placeholder={this.props.placeholder} - /> -
+ <> + +
+ + this.props.onChange(e.target.value)} + onKeyDown={this.handleKeyDown} + placeholder={this.props.placeholder} + /> +
+
+ + document.execCommand('undo')}> + {window.i18n('editMenuUndo')} + + document.execCommand('redo')}> + {window.i18n('editMenuRedo')} + +
+ document.execCommand('cut')}> + {window.i18n('editMenuCut')} + + document.execCommand('copy')}> + {window.i18n('editMenuCopy')} + + document.execCommand('paste')}> + {window.i18n('editMenuPaste')} + + document.execCommand('selectAll')}> + {window.i18n('editMenuSelectAll')} + +
+ ); } diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index bcd1beea6..9619c79ad 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -88,6 +88,7 @@ export class SessionConversation extends React.Component { this.selectMessage = this.selectMessage.bind(this); this.resetSelection = this.resetSelection.bind(this); this.updateSendingProgress = this.updateSendingProgress.bind(this); + this.resetSendingProgress = this.resetSendingProgress.bind(this); this.onMessageSending = this.onMessageSending.bind(this); this.onMessageSuccess = this.onMessageSuccess.bind(this); this.onMessageFailure = this.onMessageFailure.bind(this); @@ -148,7 +149,7 @@ export class SessionConversation extends React.Component { const selectionMode = !!this.state.selectedMessages.length; const conversation = this.props.conversations.conversationLookup[conversationKey]; - const conversationModel = window.getConversationByKey(conversationKey); + const conversationModel = window.ConversationController.get(conversationKey); const isRss = conversation.isRss; const sendMessageFn = conversationModel.sendMessage.bind(conversationModel); @@ -172,6 +173,7 @@ export class SessionConversation extends React.Component { value={this.state.sendingProgress} prevValue={this.state.prevSendingProgress} sendStatus={this.state.sendingProgressStatus} + resetProgress={this.resetSendingProgress} />
@@ -188,7 +190,7 @@ export class SessionConversation extends React.Component {
- + { showRecordingView && (
)} @@ -398,7 +400,7 @@ export class SessionConversation extends React.Component { public getHeaderProps() { const {conversationKey} = this.state; - const conversation = window.getConversationByKey(conversationKey); + const conversation = window.ConversationController.get(conversationKey); const expireTimer = conversation.get('expireTimer'); const expirationSettingName = expireTimer @@ -519,7 +521,7 @@ export class SessionConversation extends React.Component { public getGroupSettingsProps() { const { conversationKey } = this.state; - const conversation = window.getConversationByKey(conversationKey); + const conversation = window.ConversationController.get(conversationKey); const ourPK = window.textsecure.storage.user.getNumber(); const members = conversation.get('members') || []; @@ -579,7 +581,7 @@ export class SessionConversation extends React.Component { // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public updateSendingProgress(value: number, status: -1 | 0 | 1 | 2) { // If you're sending a new message, reset previous value to zero - const prevSendingProgress = 0//status === 1 ? 0 : this.state.sendingProgress; + const prevSendingProgress = status === 1 ? 0 : this.state.sendingProgress; this.setState({ sendingProgress: value, @@ -588,12 +590,20 @@ export class SessionConversation extends React.Component { }); } + public resetSendingProgress() { + this.setState({ + sendingProgress: 0, + prevSendingProgress: 0, + sendingProgressStatus: 0, + }); + } + public onMessageSending() { // Set sending state 5% to show message sending const initialValue = 5; + this.updateSendingProgress(initialValue, 1); console.log(`[sending] Message Sending`); - this.updateSendingProgress(initialValue, 1); } public onMessageSuccess(){ @@ -608,6 +618,14 @@ export class SessionConversation extends React.Component { public updateReadMessages() { const { isScrolledToBottom, messages, conversationKey } = this.state; + + // If you're not friends, don't mark anything as read. Otherwise + // this will automatically accept friend request. + const conversation = window.ConversationController.get(conversationKey); + if (!conversation.isFriend()){ + return; + } + let unread; if (!messages || messages.length === 0) { @@ -690,9 +708,35 @@ export class SessionConversation extends React.Component { // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public async handleScroll() { const messageContainer = this.messageContainerRef.current; - if (!messageContainer) return; + if (!messageContainer){ + return; + } + + + const scrollTop = messageContainer.scrollTop; + const scrollHeight = messageContainer.scrollHeight; + const clientHeight = messageContainer.clientHeight; + + const scrollButtonViewShowLimit = 0.75; + const scrollButtonViewHideLimit = 0.40; + const scrollOffsetPx = scrollHeight - scrollTop - clientHeight; + const scrollOffsetPc = scrollOffsetPx / clientHeight; + + // Scroll button appears if you're more than 75% scrolled up + if (scrollOffsetPc > scrollButtonViewShowLimit && !this.state.showScrollButton){ + this.setState({showScrollButton: true}); + } + // Scroll button disappears if you're more less than 40% scrolled up + if (scrollOffsetPc < scrollButtonViewHideLimit && this.state.showScrollButton){ + this.setState({showScrollButton: false}); + } - const isScrolledToBottom = messageContainer.scrollHeight - messageContainer.clientHeight <= messageContainer.scrollTop + 1; + console.log(`[scroll] scrollOffsetPx: `, scrollOffsetPx); + console.log(`[scroll] scrollOffsetPc: `, scrollOffsetPc); + + // Scrolled to bottom + const isScrolledToBottom = scrollOffsetPc === 0; + if (isScrolledToBottom) console.log(`[scroll] Scrolled to bottom`); // Mark messages read this.updateReadMessages(); @@ -702,16 +746,8 @@ export class SessionConversation extends React.Component { this.setState({ isScrolledToBottom }); } - // Scroll button appears if you're more than 75vh scrolled up - console.log(`[scroll] MessageContainer: `, messageContainer); - console.log(`[scroll] scrollTop: `, messageContainer.scrollTop); - console.log(`[scroll] scrollHeight: `, messageContainer.scrollHeight); - console.log(`[scroll] clientHeight: `, messageContainer.clientHeight); - - const scrollButtonViewLimit = messageContainer.clientHeight; - // Fetch more messages when nearing the top of the message list - const shouldFetchMoreMessages = messageContainer.scrollTop <= window.CONSTANTS.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; + const shouldFetchMoreMessages = scrollTop <= window.CONSTANTS.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; if (shouldFetchMoreMessages){ const numMessages = this.state.messages.length + window.CONSTANTS.DEFAULT_MESSAGE_FETCH_COUNT; diff --git a/ts/components/session/settings/SessionSettings.tsx b/ts/components/session/settings/SessionSettings.tsx index 50eae3533..b722679ea 100644 --- a/ts/components/session/settings/SessionSettings.tsx +++ b/ts/components/session/settings/SessionSettings.tsx @@ -510,6 +510,19 @@ export class SettingsView extends React.Component { content: {}, confirmationDialogParams: undefined, }, + { + id: 'auto-update', + title: window.i18n('autoUpdateSettingTitle'), + description: window.i18n('autoUpdateSettingDescription'), + hidden: false, + type: SessionSettingType.Toggle, + category: SessionSettingCategory.Privacy, + setFn: undefined, + comparisonValue: undefined, + onClick: undefined, + content: {}, + confirmationDialogParams: undefined, + }, { id: 'set-password', title: window.i18n('setAccountPasswordTitle'), diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index c951d8c46..0b81a7078 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -1,7 +1,7 @@ import { omit, reject } from 'lodash'; import { normalize } from '../../types/PhoneNumber'; -import { SearchOptions } from '../../types/Search'; +import { AdvancedSearchOptions, SearchOptions } from '../../types/Search'; import { trigger } from '../../shims/events'; import { getMessageModel } from '../../shims/Whisper'; import { cleanSearchTerm } from '../../util/cleanSearchTerm'; @@ -97,12 +97,32 @@ async function doSearch( ): Promise { const { regionCode } = options; + const advancedSearchOptions = getAdvancedSearchOptionsFromQuery(query); + const processedQuery = advancedSearchOptions.query; + const isAdvancedQuery = query !== processedQuery; + const [discussions, messages] = await Promise.all([ - queryConversationsAndContacts(query, options), - queryMessages(query), + queryConversationsAndContacts(processedQuery, options), + queryMessages(processedQuery), ]); const { conversations, contacts } = discussions; - const filteredMessages = messages.filter(message => message !== undefined); + let filteredMessages = messages.filter(message => message !== undefined); + + if (isAdvancedQuery) { + let senderFilter: Array = []; + if (advancedSearchOptions.from && advancedSearchOptions.from.length > 0) { + const senderFilterQuery = await queryConversationsAndContacts( + advancedSearchOptions.from, + options + ); + senderFilter = senderFilterQuery.contacts; + } + filteredMessages = filterMessages( + filteredMessages, + advancedSearchOptions, + senderFilter + ); + } return { query, @@ -145,6 +165,90 @@ function startNewConversation( // Helper functions for search +function filterMessages( + messages: Array, + filters: AdvancedSearchOptions, + contacts: Array +) { + let filteredMessages = messages; + if (filters.from && filters.from.length > 0) { + if (filters.from === '@me') { + filteredMessages = filteredMessages.filter(message => message.sent); + } else { + filteredMessages = []; + for (const contact of contacts) { + for (const message of messages) { + if (message.source === contact) { + filteredMessages.push(message); + } + } + } + } + } + if (filters.before > 0) { + filteredMessages = filteredMessages.filter( + message => message.received_at < filters.before + ); + } + if (filters.after > 0) { + filteredMessages = filteredMessages.filter( + message => message.received_at > filters.after + ); + } + + return filteredMessages; +} + +function getUnixMillisecondsTimestamp(timestamp: string): number { + const timestampInt = parseInt(timestamp, 10); + if (!isNaN(timestampInt)) { + try { + if (timestampInt > 10000) { + return new Date(timestampInt).getTime(); + } + + return new Date(timestamp).getTime(); + } catch (error) { + console.warn('Advanced Search: ', error); + + return 0; + } + } + + return 0; +} + +function getAdvancedSearchOptionsFromQuery( + query: string +): AdvancedSearchOptions { + const filterSeperator = ':'; + const filters: any = { + query: null, + from: null, + before: null, + after: null, + }; + + let newQuery = query; + const splitQuery = query.toLowerCase().split(' '); + const filtersList = Object.keys(filters); + for (const queryPart of splitQuery) { + for (const filter of filtersList) { + const filterMatcher = filter + filterSeperator; + if (queryPart.startsWith(filterMatcher)) { + filters[filter] = queryPart.replace(filterMatcher, ''); + newQuery = newQuery.replace(queryPart, '').trim(); + } + } + } + + filters.before = getUnixMillisecondsTimestamp(filters.before); + filters.after = getUnixMillisecondsTimestamp(filters.after); + filters.query = newQuery; + + return filters; +} + const getMessageProps = (messages: Array) => { if (!messages || !messages.length) { return []; diff --git a/ts/types/Search.ts b/ts/types/Search.ts index debd559a0..b24914d8e 100644 --- a/ts/types/Search.ts +++ b/ts/types/Search.ts @@ -4,3 +4,10 @@ export type SearchOptions = { noteToSelf: string; isSecondaryDevice: boolean; }; + +export type AdvancedSearchOptions = { + query: string; + from?: string; + before: number; + after: number; +}; diff --git a/ts/updater/index.ts b/ts/updater/index.ts index dc1ec1310..036344388 100644 --- a/ts/updater/index.ts +++ b/ts/updater/index.ts @@ -1,12 +1,15 @@ import { get as getFromConfig } from 'config'; import { BrowserWindow } from 'electron'; -import { start as startUpdater } from './updater'; +import { start as startUpdater, stop as stopUpdater } from './updater'; import { LoggerType, MessagesType } from './common'; +import { UserConfig } from '../../app/user_config'; let initialized = false; +let config: UserConfig; export async function start( getMainWindow: () => BrowserWindow, + userConfig: UserConfig, messages?: MessagesType, logger?: LoggerType ) { @@ -14,6 +17,7 @@ export async function start( throw new Error('updater/start: Updates have already been initialized!'); } initialized = true; + config = userConfig; if (!messages) { throw new Error('updater/start: Must provide messages!'); @@ -40,8 +44,18 @@ export async function start( await startUpdater(getMainWindow, messages, logger); } +export function stop() { + if (initialized) { + stopUpdater(); + initialized = false; + } +} + function autoUpdateDisabled() { return ( - process.mas || !getFromConfig('updatesEnabled') // From Electron: Mac App Store build + process.mas || // From Electron: Mac App Store build + !getFromConfig('updatesEnabled') || // Hard coded config + // tslint:disable-next-line: no-backbone-get-set-outside-model + !config.get('autoUpdate') // User setting ); } diff --git a/ts/updater/updater.ts b/ts/updater/updater.ts index abde36b06..f31ddfa12 100644 --- a/ts/updater/updater.ts +++ b/ts/updater/updater.ts @@ -3,6 +3,7 @@ import * as fs from 'fs-extra'; import { autoUpdater, UpdateInfo } from 'electron-updater'; import { app, BrowserWindow } from 'electron'; import { markShouldQuit } from '../../app/window_state'; + import { getPrintableError, LoggerType, @@ -15,6 +16,8 @@ import { gt as isVersionGreaterThan, parse as parseVersion } from 'semver'; let isUpdating = false; let downloadIgnored = false; +let interval: NodeJS.Timeout | undefined; +let stopped = false; const SECOND = 1000; const MINUTE = SECOND * 60; @@ -25,28 +28,43 @@ export async function start( messages: MessagesType, logger: LoggerType ) { + if (interval) { + logger.info('auto-update: Already running'); + + return; + } + logger.info('auto-update: starting checks...'); autoUpdater.logger = logger; autoUpdater.autoDownload = false; - setInterval(async () => { + interval = setInterval(async () => { try { await checkForUpdates(getMainWindow, messages, logger); } catch (error) { logger.error('auto-update: error:', getPrintableError(error)); } }, INTERVAL); + stopped = false; await checkForUpdates(getMainWindow, messages, logger); } +export function stop() { + if (interval) { + clearInterval(interval); + interval = undefined; + } + stopped = true; +} + async function checkForUpdates( getMainWindow: () => BrowserWindow, messages: MessagesType, logger: LoggerType ) { - if (isUpdating || downloadIgnored) { + if (stopped || isUpdating || downloadIgnored) { return; }