diff --git a/_locales/en/messages.json b/_locales/en/messages.json index daa221739..729cc1181 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -948,6 +948,10 @@ "delete": { "message": "Delete" }, + "deletePublicWarning": { + "message": + "Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel." + }, "deleteWarning": { "message": "Are you sure? Clicking 'delete' will permanently remove this message from this device only." diff --git a/app/sql.js b/app/sql.js index ac5d5ec2c..bd1e4f1e6 100644 --- a/app/sql.js +++ b/app/sql.js @@ -94,9 +94,14 @@ module.exports = { saveConversation, saveConversations, getConversationById, + savePublicServerToken, + getPublicServerTokenByServerUrl, updateConversation, removeConversation, getAllConversations, + getAllRssFeedConversations, + getAllPublicConversations, + getPublicConversationsByServer, getPubKeysWithFriendStatus, getAllConversationIds, getAllPrivateConversations, @@ -117,6 +122,7 @@ module.exports = { removeMessage, getUnreadByConversation, getMessageBySender, + getMessageByServerId, getMessageById, getAllMessages, getAllMessageIds, @@ -770,6 +776,188 @@ async function updateSchema(instance) { // eslint-disable-next-line no-await-in-loop await runSchemaUpdate(schemaVersion, instance); } + await updateLokiSchema(instance); +} + +const LOKI_SCHEMA_VERSIONS = [updateToLokiSchemaVersion1]; + +async function updateToLokiSchemaVersion1(currentVersion, instance) { + if (currentVersion >= 1) { + return; + } + console.log('updateToLokiSchemaVersion1: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + await instance.run( + `ALTER TABLE messages + ADD COLUMN serverId INTEGER;` + ); + + await instance.run( + `CREATE TABLE servers( + serverUrl STRING PRIMARY KEY ASC, + token TEXT + );` + ); + + const initConversation = async data => { + const { id, type, name, friendRequestStatus } = data; + await instance.run( + `INSERT INTO conversations ( + id, + json, + + type, + members, + name, + friendRequestStatus + ) values ( + $id, + $json, + + $type, + $members, + $name, + $friendRequestStatus + );`, + { + $id: id, + $json: objectToJSON(data), + + $type: type, + $members: null, + $name: name, + $friendRequestStatus: friendRequestStatus, + } + ); + }; + + const lokiPublicServerData = { + serverUrl: 'https://chat.lokinet.org', + token: null, + }; + + const baseData = { + friendRequestStatus: 4, // Friends + sealedSender: 0, + sessionResetStatus: 0, + swarmNodes: [], + type: 'group', + unlockTimestamp: null, + unreadCount: 0, + verified: 0, + version: 2, + }; + + const publicChatData = { + ...baseData, + id: 'publicChat:1@chat.lokinet.org', + server: lokiPublicServerData.serverUrl, + name: 'Loki Public Chat', + channelId: '1', + }; + + const { serverUrl, token } = lokiPublicServerData; + + await instance.run( + `INSERT INTO servers ( + serverUrl, + token + ) values ( + $serverUrl, + $token + );`, + { + $serverUrl: serverUrl, + $token: token, + } + ); + + const newsRssFeedData = { + ...baseData, + id: 'rss://loki.network/feed/', + rssFeed: 'https://loki.network/feed/', + closable: true, + name: 'Loki.network News', + profileAvatar: 'images/loki/loki_icon.png', + }; + + const updatesRssFeedData = { + ...baseData, + id: 'rss://loki.network/category/messenger-updates/feed/', + rssFeed: 'https://loki.network/category/messenger-updates/feed/', + closable: false, + name: 'Messenger updates', + profileAvatar: 'images/loki/loki_icon.png', + }; + + await initConversation(publicChatData); + await initConversation(newsRssFeedData); + await initConversation(updatesRssFeedData); + + await instance.run( + `INSERT INTO loki_schema ( + version + ) values ( + 1 + );` + ); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToLokiSchemaVersion1: success!'); +} + +async function updateLokiSchema(instance) { + const result = await instance.get( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';" + ); + if (!result) { + await createLokiSchemaTable(instance); + } + const lokiSchemaVersion = await getLokiSchemaVersion(instance); + console.log( + 'updateLokiSchema:', + `Current loki schema version: ${lokiSchemaVersion};`, + `Most recent schema version: ${LOKI_SCHEMA_VERSIONS.length};` + ); + for ( + let index = 0, max = LOKI_SCHEMA_VERSIONS.length; + index < max; + index += 1 + ) { + const runSchemaUpdate = LOKI_SCHEMA_VERSIONS[index]; + + // Yes, we really want to do this asynchronously, in order + // eslint-disable-next-line no-await-in-loop + await runSchemaUpdate(lokiSchemaVersion, instance); + } +} + +async function getLokiSchemaVersion(instance) { + const result = await instance.get( + 'SELECT MAX(version) as version FROM loki_schema;' + ); + if (!result || !result.version) { + return 0; + } + return result.version; +} + +async function createLokiSchemaTable(instance) { + await instance.run('BEGIN TRANSACTION;'); + await instance.run( + `CREATE TABLE loki_schema( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + version INTEGER + );` + ); + await instance.run( + `INSERT INTO loki_schema ( + version + ) values ( + 0 + );` + ); + await instance.run('COMMIT TRANSACTION;'); } let db; @@ -1433,6 +1621,38 @@ async function removeConversation(id) { ); } +async function savePublicServerToken(data) { + const { serverUrl, token } = data; + await db.run( + `INSERT OR REPLACE INTO servers ( + serverUrl, + token + ) values ( + $serverUrl, + $token + )`, + { + $serverUrl: serverUrl, + $token: token, + } + ); +} + +async function getPublicServerTokenByServerUrl(serverUrl) { + const row = await db.get( + 'SELECT * FROM servers WHERE serverUrl = $serverUrl;', + { + $serverUrl: serverUrl, + } + ); + + if (!row) { + return null; + } + + return row.token; +} + async function getConversationById(id) { const row = await db.get('SELECT * FROM conversations WHERE id = $id;', { $id: id, @@ -1477,6 +1697,41 @@ async function getAllPrivateConversations() { return map(rows, row => jsonToObject(row.json)); } +async function getAllRssFeedConversations() { + const rows = await db.all( + `SELECT json FROM conversations WHERE + type = 'group' AND + id LIKE 'rss://%' + ORDER BY id ASC;` + ); + + return map(rows, row => jsonToObject(row.json)); +} + +async function getAllPublicConversations() { + const rows = await db.all( + `SELECT json FROM conversations WHERE + type = 'group' AND + id LIKE 'publicChat:%' + ORDER BY id ASC;` + ); + + return map(rows, row => jsonToObject(row.json)); +} + +async function getPublicConversationsByServer(server) { + const rows = await db.all( + `SELECT * FROM conversations WHERE + server = $server + ORDER BY id ASC;`, + { + $server: server, + } + ); + + return map(rows, row => jsonToObject(row.json)); +} + async function getAllGroupsInvolvingId(id) { const rows = await db.all( `SELECT json FROM conversations WHERE @@ -1584,6 +1839,7 @@ async function saveMessage(data, { forceSave } = {}) { hasFileAttachments, hasVisualMediaAttachments, id, + serverId, // eslint-disable-next-line camelcase received_at, schemaVersion, @@ -1602,6 +1858,7 @@ async function saveMessage(data, { forceSave } = {}) { $id: id, $json: objectToJSON(data), + $serverId: serverId, $body: body, $conversationId: conversationId, $expirationStartTimestamp: expirationStartTimestamp, @@ -1624,6 +1881,7 @@ async function saveMessage(data, { forceSave } = {}) { await db.run( `UPDATE messages SET json = $json, + serverId = $serverId, body = $body, conversationId = $conversationId, expirationStartTimestamp = $expirationStartTimestamp, @@ -1658,6 +1916,7 @@ async function saveMessage(data, { forceSave } = {}) { id, json, + serverId, body, conversationId, expirationStartTimestamp, @@ -1678,6 +1937,7 @@ async function saveMessage(data, { forceSave } = {}) { $id, $json, + $serverId, $body, $conversationId, $expirationStartTimestamp, @@ -1800,6 +2060,24 @@ async function removeMessage(id) { ); } +async function getMessageByServerId(serverId, conversationId) { + const row = await db.get( + `SELECT * FROM messages WHERE + serverId = $serverId AND + conversationId = $conversationId;`, + { + $serverId: serverId, + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return jsonToObject(row.json); +} + async function getMessageById(id) { const row = await db.get('SELECT * FROM messages WHERE id = $id;', { $id: id, diff --git a/config/default.json b/config/default.json index c6ead5686..8740d6b93 100644 --- a/config/default.json +++ b/config/default.json @@ -4,7 +4,7 @@ "cdnUrl": "random.snode", "contentProxyUrl": "", "localServerPort": "8081", - "defaultPoWDifficulty": "100", + "defaultPoWDifficulty": "1", "seedNodeList": [ { "ip": "storage.testnetseed1.loki.network", diff --git a/js/background.js b/js/background.js index 3cd81dcbd..85f53af2f 100644 --- a/js/background.js +++ b/js/background.js @@ -204,9 +204,36 @@ window.log.info('Storage fetch'); storage.fetch(); - const initAPIs = () => { + const initSpecialConversations = async () => { + const rssFeedConversations = await window.Signal.Data.getAllRssFeedConversations( + { + ConversationCollection: Whisper.ConversationCollection, + } + ); + rssFeedConversations.forEach(conversation => { + window.feeds.push(new window.LokiRssAPI(conversation.getRssSettings())); + }); + const publicConversations = await window.Signal.Data.getAllPublicConversations( + { + ConversationCollection: Whisper.ConversationCollection, + } + ); + publicConversations.forEach(conversation => { + const settings = conversation.getPublicSource(); + const channel = window.lokiPublicChatAPI.findOrCreateChannel( + settings.server, + settings.channelId, + conversation.id + ); + channel.refreshModStatus(); + }); + }; + + const initAPIs = async () => { const ourKey = textsecure.storage.user.getNumber(); + window.feeds = []; window.lokiMessageAPI = new window.LokiMessageAPI(ourKey); + window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey); window.lokiP2pAPI = new window.LokiP2pAPI(ourKey); window.lokiP2pAPI.on('pingContact', pubKey => { const isPing = true; @@ -244,11 +271,6 @@ } first = false; - if (Whisper.Registration.isDone()) { - startLocalLokiServer(); - initAPIs(); - } - const currentPoWDifficulty = storage.get('PoWDifficulty', null); if (!currentPoWDifficulty) { storage.put('PoWDifficulty', window.getDefaultPoWDifficulty()); @@ -464,6 +486,28 @@ } }); + Whisper.events.on( + 'deleteLocalPublicMessage', + async ({ messageServerId, conversationId }) => { + const message = await window.Signal.Data.getMessageByServerId( + messageServerId, + conversationId, + { + Message: Whisper.Message, + } + ); + if (message) { + const conversation = ConversationController.get(conversationId); + if (conversation) { + conversation.removeMessage(message.id); + } + await window.Signal.Data.removeMessage(message.id, { + Message: Whisper.Message, + }); + } + } + ); + Whisper.events.on('setupAsNewDevice', () => { const { appView } = window.owsDesktopApp; if (appView) { @@ -556,11 +600,9 @@ window.log.info('Cleanup: complete'); window.log.info('listening for registration events'); - Whisper.events.on('registration_done', () => { + Whisper.events.on('registration_done', async () => { window.log.info('handling registration event'); - startLocalLokiServer(); - // listeners Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); // window.Signal.RefreshSenderCertificate.initialize({ @@ -570,7 +612,6 @@ // logger: window.log, // }); - initAPIs(); connect(true); }); @@ -729,6 +770,18 @@ } }); + Whisper.events.on( + 'publicMessageSent', + ({ pubKey, timestamp, serverId }) => { + try { + const conversation = ConversationController.get(pubKey); + conversation.onPublicMessageSent(pubKey, timestamp, serverId); + } catch (e) { + window.log.error('Error setting public on message'); + } + } + ); + Whisper.events.on('password-updated', () => { if (appView && appView.inboxView) { appView.inboxView.trigger('password-updated'); @@ -832,6 +885,9 @@ Whisper.Notifications.disable(); // avoid notification flood until empty // initialize the socket and start listening for messages + startLocalLokiServer(); + await initAPIs(); + await initSpecialConversations(); messageReceiver = new textsecure.MessageReceiver( USERNAME, PASSWORD, @@ -1245,6 +1301,18 @@ return handleProfileUpdate({ data, confirm, messageDescriptor }); } + const ourNumber = textsecure.storage.user.getNumber(); + const descriptorId = await textsecure.MessageReceiver.arrayBufferToString( + messageDescriptor.id + ); + if ( + messageDescriptor.type === 'group' && + descriptorId.match(/^publicChat:/) && + data.source === ourNumber + ) { + // Remove public chat messages to ourselves + return event.confirm(); + } const message = await createMessage(data); const isDuplicate = await isMessageDuplicate(message); if (isDuplicate) { @@ -1371,6 +1439,7 @@ let messageData = { source: data.source, sourceDevice: data.sourceDevice, + serverId: data.serverId, sent_at: data.timestamp, received_at: data.receivedAt || Date.now(), conversationId: data.source, @@ -1378,6 +1447,8 @@ type: 'incoming', unread: 1, isP2p: data.isP2p, + isPublic: data.isPublic, + isRss: data.isRss, }; if (data.friendRequest) { diff --git a/js/conversation_controller.js b/js/conversation_controller.js index fe2a45074..50b3aae33 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -1,4 +1,4 @@ -/* global _, Whisper, Backbone, storage, textsecure, libsignal */ +/* global _, Whisper, Backbone, storage, textsecure, libsignal, lokiPublicChatAPI */ /* eslint-disable more/no-then */ @@ -159,6 +159,10 @@ if (!conversation) { return; } + if (conversation.isPublic()) { + const server = conversation.getPublicSource(); + lokiPublicChatAPI.unregisterChannel(server.server, server.channelId); + } await conversation.destroyMessages(); const deviceIds = await textsecure.storage.protocol.getDeviceIds(id); await Promise.all( diff --git a/js/models/conversations.js b/js/models/conversations.js index 891a345d5..0829a5a8e 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -11,6 +11,7 @@ clipboard, BlockedNumberController, lokiP2pAPI, + lokiPublicChatAPI, JobQueue */ @@ -167,7 +168,7 @@ if (this.id === this.ourNumber) { this.set({ friendRequestStatus: FriendRequestStatusEnum.friends }); - } else if (lokiP2pAPI) { + } else if (typeof lokiP2pAPI !== 'undefined') { // Online status handling, only for contacts that aren't us this.set({ isOnline: lokiP2pAPI.isOnline(this.id) }); } else { @@ -193,6 +194,15 @@ isMe() { return this.id === this.ourNumber; }, + isPublic() { + return this.id.match(/^publicChat:/); + }, + isClosable() { + return !this.isRss() || this.get('closable'); + }, + isRss() { + return this.id && this.id.match(/^rss:/); + }, isBlocked() { return BlockedNumberController.isBlocked(this.id); }, @@ -299,6 +309,7 @@ }, async updateProfileAvatar() { + if (this.isRss()) return; const path = profileImages.getOrCreateImagePath(this.id); await this.setProfileAvatar(path); }, @@ -365,6 +376,16 @@ await Promise.all(messages.map(m => m.setIsP2p(true))); }, + async onPublicMessageSent(pubKey, timestamp, serverId) { + const messages = this._getMessagesWithTimestamp(pubKey, timestamp); + await Promise.all( + messages.map(message => [ + message.setIsPublic(true), + message.setServerId(serverId), + ]) + ); + }, + async onNewMessage(message) { await this.updateLastMessage(); @@ -424,6 +445,7 @@ color, type: this.isPrivate() ? 'direct' : 'group', isMe: this.isMe(), + isClosable: this.isClosable(), isTyping: typingKeys.length > 0, lastUpdated: this.get('timestamp'), name: this.getName(), @@ -440,6 +462,7 @@ lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), + isRss: this.isRss(), }, isOnline: this.isOnline(), hasNickname: !!this.getNickname(), @@ -629,6 +652,11 @@ ); }, updateTextInputState() { + if (this.isRss()) { + // or if we're an rss conversation, disable it + this.trigger('disable:input', true); + return; + } switch (this.get('friendRequestStatus')) { case FriendRequestStatusEnum.none: case FriendRequestStatusEnum.requestExpired: @@ -1347,6 +1375,10 @@ const options = this.getSendOptions(); options.messageType = message.get('type'); + options.isPublic = this.isPublic(); + if (options.isPublic) { + options.publicSendData = await this.getPublicSendData(); + } const groupNumbers = this.getRecipients(); @@ -1946,7 +1978,7 @@ return; } - if (read.length && options.sendReadReceipts) { + if (!this.isPublic() && read.length && options.sendReadReceipts) { window.log.info(`Sending ${read.length} read receipts`); // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. @@ -2015,6 +2047,73 @@ getNickname() { return this.get('nickname'); }, + getRssSettings() { + if (!this.isRss()) { + return null; + } + return { + RSS_FEED: this.get('rssFeed'), + CONVO_ID: this.id, + title: this.get('name'), + closeable: this.get('closable'), + }; + }, + // maybe "Backend" instead of "Source"? + getPublicSource() { + if (!this.isPublic()) { + return null; + } + return { + server: this.get('server'), + channelId: this.get('channelId'), + conversationId: this.get('id'), + }; + }, + async getPublicSendData() { + const serverAPI = lokiPublicChatAPI.findOrCreateServer( + this.get('server') + ); + const channelAPI = serverAPI.findOrCreateChannel( + this.get('channelId'), + this.id + ); + return channelAPI; + }, + getLastRetrievedMessage() { + if (!this.isPublic()) { + return null; + } + const lastMessageId = this.get('lastPublicMessage') || 0; + return lastMessageId; + }, + async setLastRetrievedMessage(newLastMessageId) { + if (!this.isPublic()) { + return; + } + if (this.get('lastPublicMessage') !== newLastMessageId) { + this.set({ lastPublicMessage: newLastMessageId }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + }, + isModerator() { + if (!this.isPublic()) { + return false; + } + return this.get('modStatus'); + }, + async setModStatus(newStatus) { + if (!this.isPublic()) { + return; + } + if (this.get('modStatus') !== newStatus) { + this.set({ modStatus: newStatus }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + }, // SIGNAL PROFILES @@ -2052,6 +2151,23 @@ }); } }, + async setGroupNameAndAvatar(name, avatarPath) { + const currentName = this.get('name'); + const profileAvatar = this.get('profileAvatar'); + if (profileAvatar !== avatarPath || currentName !== name) { + // only update changed items + if (profileAvatar !== avatarPath) { + this.set({ profileAvatar: avatarPath }); + } + if (currentName !== name) { + this.set({ name }); + } + // save + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + }, async setProfileAvatar(avatarPath) { const profileAvatar = this.get('profileAvatar'); if (profileAvatar !== avatarPath) { @@ -2201,6 +2317,31 @@ }); }, + async deletePublicMessage(message) { + const serverAPI = lokiPublicChatAPI.findOrCreateServer( + this.get('server') + ); + const channelAPI = serverAPI.findOrCreateChannel( + this.get('channelId'), + this.id + ); + const success = await channelAPI.deleteMessage(message.getServerId()); + if (success) { + this.removeMessage(message.id); + } + return success; + }, + + removeMessage(messageId) { + const message = this.messageCollection.models.find( + msg => msg.id === messageId + ); + if (message) { + message.trigger('unload'); + this.messageCollection.remove(messageId); + } + }, + deleteMessages() { Whisper.events.trigger('showConfirmationDialog', { message: i18n('deleteConversationConfirmation'), diff --git a/js/models/messages.js b/js/models/messages.js index 8f91658a8..e584b95a6 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -670,8 +670,15 @@ expirationLength, expirationTimestamp, isP2p: !!this.get('isP2p'), + isPublic: !!this.get('isPublic'), + isRss: !!this.get('isRss'), + isDeletable: + !this.get('isPublic') || + this.getConversation().isModerator() || + this.getSource() === this.OUR_NUMBER, onCopyText: () => this.copyText(), + onCopyPubKey: () => this.copyPubKey(), onReply: () => this.trigger('reply', this), onRetrySend: () => this.retrySend(), onShowDetail: () => this.trigger('show-message-detail', this), @@ -958,6 +965,17 @@ }; }, + copyPubKey() { + if (this.isIncoming()) { + clipboard.writeText(this.get('source')); + } else { + clipboard.writeText(this.OUR_NUMBER); + } + window.Whisper.events.trigger('showToast', { + message: i18n('copiedPublicKey'), + }); + }, + copyText() { clipboard.writeText(this.get('body')); window.Whisper.events.trigger('showToast', { @@ -1238,6 +1256,31 @@ Message: Whisper.Message, }); }, + getServerId() { + return this.get('serverId'); + }, + async setServerId(serverId) { + if (_.isEqual(this.get('serverId'), serverId)) return; + + this.set({ + serverId, + }); + + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + }, + async setIsPublic(isPublic) { + if (_.isEqual(this.get('isPublic'), isPublic)) return; + + this.set({ + isPublic: !!isPublic, + }); + + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + }, send(promise) { this.trigger('pending'); return promise @@ -1960,6 +2003,12 @@ } ); } + } else if (dataMessage.profile) { + ConversationController.getOrCreateAndWait(source, 'private').then( + sender => { + sender.setProfile(dataMessage.profile); + } + ); } let autoAccept = false; diff --git a/js/modules/data.js b/js/modules/data.js index e5a9e1af1..c410f9464 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -118,6 +118,11 @@ module.exports = { getPubKeysWithFriendStatus, getAllConversationIds, getAllPrivateConversations, + getAllRssFeedConversations, + getAllPublicConversations, + getPublicConversationsByServer, + savePublicServerToken, + getPublicServerTokenByServerUrl, getAllGroupsInvolvingId, searchConversations, @@ -140,6 +145,7 @@ module.exports = { removeAllMessagesInConversation, getMessageBySender, + getMessageByServerId, getMessageById, getAllMessages, getAllUnsentMessages, @@ -739,6 +745,22 @@ async function getAllConversationIds() { return ids; } +async function getAllRssFeedConversations({ ConversationCollection }) { + const conversations = await channels.getAllRssFeedConversations(); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + +async function getAllPublicConversations({ ConversationCollection }) { + const conversations = await channels.getAllPublicConversations(); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + async function getAllPrivateConversations({ ConversationCollection }) { const conversations = await channels.getAllPrivateConversations(); @@ -747,6 +769,26 @@ async function getAllPrivateConversations({ ConversationCollection }) { return collection; } +async function savePublicServerToken(data) { + await channels.savePublicServerToken(data); +} + +async function getPublicServerTokenByServerUrl(serverUrl) { + const token = await channels.getPublicServerTokenByServerUrl(serverUrl); + return token; +} + +async function getPublicConversationsByServer( + server, + { ConversationCollection } +) { + const conversations = await channels.getPublicConversationsByServer(server); + + const collection = new ConversationCollection(); + collection.add(conversations); + return collection; +} + async function getAllGroupsInvolvingId(id, { ConversationCollection }) { const conversations = await channels.getAllGroupsInvolvingId(id); @@ -866,6 +908,15 @@ async function _removeMessages(ids) { await channels.removeMessage(ids); } +async function getMessageByServerId(serverId, conversationId, { Message }) { + const message = await channels.getMessageByServerId(serverId, conversationId); + if (!message) { + return null; + } + + return new Message(message); +} + async function getMessageById(id, { Message }) { const message = await channels.getMessageById(id); if (!message) { diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index 199448a57..227541929 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -75,13 +75,40 @@ class LokiMessageAPI { } async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) { - const { isPing = false, numConnections = DEFAULT_CONNECTIONS } = options; + const { + isPing = false, + isPublic = false, + numConnections = DEFAULT_CONNECTIONS, + publicSendData = null, + } = options; // Data required to identify a message in a conversation const messageEventData = { pubKey, timestamp: messageTimeStamp, }; + if (isPublic) { + const { profile } = data; + let displayName = 'Anonymous'; + if (profile && profile.displayName) { + ({ displayName } = profile); + } + const res = await publicSendData.sendMessage( + data.body, + messageTimeStamp, + displayName, + this.ourKey + ); + if (res === false) { + throw new window.textsecure.PublicChatError( + 'Failed to send public chat message' + ); + } + messageEventData.serverId = res; + window.Whisper.events.trigger('publicMessageSent', messageEventData); + return; + } + const data64 = dcodeIO.ByteBuffer.wrap(data).toString('base64'); const p2pSuccess = await trySendP2p( pubKey, diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js new file mode 100644 index 000000000..7a959c316 --- /dev/null +++ b/js/modules/loki_public_chat_api.js @@ -0,0 +1,530 @@ +/* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController */ +const EventEmitter = require('events'); +const nodeFetch = require('node-fetch'); +const { URL, URLSearchParams } = require('url'); + +// Can't be less than 1200 if we have unauth'd requests +const GROUPCHAT_POLL_EVERY = 1500; // 1.5s +const DELETION_POLL_EVERY = 5000; // 1 second + +// singleton to relay events to libtextsecure/message_receiver +class LokiPublicChatAPI extends EventEmitter { + constructor(ourKey) { + super(); + this.ourKey = ourKey; + this.servers = []; + } + + // server getter/factory + findOrCreateServer(serverUrl) { + let thisServer = this.servers.find( + server => server.baseServerUrl === serverUrl + ); + if (!thisServer) { + log.info(`LokiPublicChatAPI creating ${serverUrl}`); + thisServer = new LokiPublicServerAPI(this, serverUrl); + this.servers.push(thisServer); + } + return thisServer; + } + + // channel getter/factory + findOrCreateChannel(serverUrl, channelId, conversationId) { + const server = this.findOrCreateServer(serverUrl); + return server.findOrCreateChannel(channelId, conversationId); + } + + // deallocate resources server uses + unregisterChannel(serverUrl, channelId) { + let thisServer; + let i = 0; + for (; i < this.servers.length; i += 1) { + if (this.servers[i].server === serverUrl) { + thisServer = this.servers[i]; + break; + } + } + + if (!thisServer) { + log.warn(`Tried to unregister from nonexistent server ${serverUrl}`); + return; + } + thisServer.unregisterChannel(channelId); + this.servers.splice(i, 1); + } +} + +class LokiPublicServerAPI { + constructor(chatAPI, url) { + this.chatAPI = chatAPI; + this.channels = []; + this.tokenPromise = null; + this.baseServerUrl = url; + const ref = this; + (async function justToEnableAsyncToGetToken() { + ref.token = await ref.getOrRefreshServerToken(); + log.info(`set token ${ref.token}`); + })(); + } + + // channel getter/factory + findOrCreateChannel(channelId, conversationId) { + let thisChannel = this.channels.find( + channel => channel.channelId === channelId + ); + if (!thisChannel) { + log.info(`LokiPublicChatAPI creating channel ${conversationId}`); + thisChannel = new LokiPublicChannelAPI(this, channelId, conversationId); + this.channels.push(thisChannel); + } + return thisChannel; + } + + // deallocate resources channel uses + unregisterChannel(channelId) { + let thisChannel; + let i = 0; + for (; i < this.channels.length; i += 1) { + if (this.channels[i].channelId === channelId) { + thisChannel = this.channels[i]; + break; + } + } + if (!thisChannel) { + return; + } + this.channels.splice(i, 1); + thisChannel.stopPolling = true; + } + + // get active token for this server + async getOrRefreshServerToken(forceRefresh = false) { + let token; + if (!forceRefresh) { + if (this.token) { + return this.token; + } + token = await Signal.Data.getPublicServerTokenByServerUrl( + this.baseServerUrl + ); + } + if (!token) { + token = await this.refreshServerToken(); + if (token) { + await Signal.Data.savePublicServerToken({ + serverUrl: this.baseServerUrl, + token, + }); + } + } + this.token = token; + return token; + } + + // get active token from server (but only allow one request at a time) + async refreshServerToken() { + // if currently not in progress + if (this.tokenPromise === null) { + // set lock + this.tokenPromise = new Promise(async res => { + // request the oken + const token = await this.requestToken(); + if (!token) { + res(null); + return; + } + // activate the token + const registered = await this.submitToken(token); + if (!registered) { + res(null); + return; + } + // resolve promise to release lock + res(token); + }); + } + // wait until we have it set + const token = await this.tokenPromise; + // clear lock + this.tokenPromise = null; + return token; + } + + // request an token from the server + async requestToken() { + const url = new URL(`${this.baseServerUrl}/loki/v1/get_challenge`); + const params = { + pubKey: this.chatAPI.ourKey, + }; + url.search = new URLSearchParams(params); + + let res; + try { + res = await nodeFetch(url); + } catch (e) { + return null; + } + if (!res.ok) { + return null; + } + const body = await res.json(); + const token = await libloki.crypto.decryptToken(body); + return token; + } + + // activate token + async submitToken(token) { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + pubKey: this.chatAPI.ourKey, + token, + }), + }; + + try { + const res = await nodeFetch( + `${this.baseServerUrl}/loki/v1/submit_challenge`, + options + ); + return res.ok; + } catch (e) { + return false; + } + } +} + +class LokiPublicChannelAPI { + constructor(serverAPI, channelId, conversationId) { + // properties + this.serverAPI = serverAPI; + this.channelId = channelId; + this.baseChannelUrl = `channels/${this.channelId}`; + this.groupName = 'unknown'; + this.conversationId = conversationId; + this.lastGot = null; + this.stopPolling = false; + this.modStatus = false; + this.deleteLastId = 1; + // end properties + + log.info(`registered LokiPublicChannel ${channelId}`); + // start polling + this.pollForMessages(); + this.pollForDeletions(); + } + + // make a request to the server + async serverRequest(endpoint, options = {}) { + const { params = {}, method, objBody, forceFreshToken = false } = options; + const url = new URL(`${this.serverAPI.baseServerUrl}/${endpoint}`); + if (params) { + url.search = new URLSearchParams(params); + } + let result; + let { token } = this.serverAPI; + if (!token) { + token = await this.serverAPI.getOrRefreshServerToken(); + if (!token) { + log.error('NO TOKEN'); + return { + err: 'noToken', + }; + } + } + try { + const fetchOptions = {}; + const headers = { + Authorization: `Bearer ${this.serverAPI.token}`, + }; + if (method) { + fetchOptions.method = method; + } + if (objBody) { + headers['Content-Type'] = 'application/json'; + fetchOptions.body = JSON.stringify(objBody); + } + fetchOptions.headers = new Headers(headers); + result = await nodeFetch(url, fetchOptions || undefined); + } catch (e) { + log.info(`e ${e}`); + return { + err: e, + }; + } + let response = null; + try { + response = await result.json(); + } catch (e) { + log.info(`serverRequest json arpse ${e}`); + return { + err: e, + statusCode: result.status, + }; + } + + // if it's a response style with a meta + if (result.status !== 200) { + if (!forceFreshToken && 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, + }; + } + + // get moderator status + async refreshModStatus() { + const res = this.serverRequest('loki/v1/user_info'); + // if no problems and we have data + if (!res.err && res.response && res.response.data) { + this.modStatus = res.response.data.moderator_status; + } + + const conversation = ConversationController.get(this.conversationId); + await conversation.setModStatus(this.modStatus); + } + + // delete a message on the server + async deleteMessage(serverId) { + const res = await this.serverRequest( + this.modStatus + ? `loki/v1/moderation/message/${serverId}` + : `${this.baseChannelUrl}/messages/${serverId}`, + { method: 'DELETE' } + ); + if (!res.err && res.response) { + log.info(`deleted ${serverId} on ${this.baseChannelUrl}`); + return true; + } + log.warn(`failed to delete ${serverId} on ${this.baseChannelUrl}`); + return false; + } + + // used for sending messages + getEndpoint() { + const endpoint = `${this.serverAPI.baseServerUrl}/${ + this.baseChannelUrl + }/messages`; + return endpoint; + } + + // update room details + async pollForChannel() { + // groupName will be loaded from server + const url = new URL(this.baseChannelUrl); + let res; + const token = await this.serverAPI.getOrRefreshServerToken(); + if (!token) { + log.error('NO TOKEN'); + return { + err: 'noToken', + }; + } + try { + // eslint-disable-next-line no-await-in-loop + const options = { + headers: new Headers({ + Authorization: `Bearer ${token}`, + }), + }; + res = await nodeFetch(url, options || undefined); + } catch (e) { + log.info(`e ${e}`); + return { + err: e, + }; + } + // eslint-disable-next-line no-await-in-loop + const response = await res.json(); + if (response.meta.code !== 200) { + return { + err: 'statusCode', + response, + }; + } + return { + response, + }; + } + + // get moderation actions + async pollForDeletions() { + // grab the last 200 deletions + const params = { + count: 200, + }; + + // start loop + let more = true; + while (more) { + // set params to from where we last checked + params.since_id = this.deleteLastId; + + // grab the next 200 deletions from where we last checked + // eslint-disable-next-line no-await-in-loop + const res = await this.serverRequest( + `loki/v1/channel/${this.channelId}/deletes`, + { params } + ); + + // Process results + res.response.data.reverse().forEach(deleteEntry => { + // Escalate it up to the subsystem that can check to see if this has + // been processed + Whisper.events.trigger('deleteLocalPublicMessage', { + messageServerId: deleteEntry.message_id, + conversationId: this.conversationId, + }); + }); + + // if we had a problem break the loop + if (res.response.data.length < 200) { + break; + } + + // update where we last checked + this.deleteLastId = res.response.meta.max_id; + ({ more } = res.response); + } + + // set up next poll + setTimeout(() => { + this.pollForDeletions(); + }, DELETION_POLL_EVERY); + } + + // get channel messages + async pollForMessages() { + const params = { + include_annotations: 1, + count: -20, + include_deleted: false, + }; + const conversation = ConversationController.get(this.conversationId); + if (!conversation) { + log.warn('Trying to poll for non-existing public conversation'); + this.lastGot = 0; + } else if (!this.lastGot) { + this.lastGot = conversation.getLastRetrievedMessage(); + } + params.since_id = this.lastGot; + const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { + params, + }); + + if (!res.err && res.response) { + let receivedAt = new Date().getTime(); + res.response.data.reverse().forEach(adnMessage => { + let timestamp = new Date(adnMessage.created_at).getTime(); + let from = adnMessage.user.username; + let source; + if (adnMessage.is_deleted) { + return; + } + if (adnMessage.annotations !== []) { + const noteValue = adnMessage.annotations[0].value; + ({ from, timestamp, source } = noteValue); + } + + if ( + !from || + !timestamp || + !source || + !adnMessage.id || + !adnMessage.text + ) { + return; // Invalid message + } + + const messageData = { + serverId: adnMessage.id, + friendRequest: false, + source, + sourceDevice: 1, + timestamp, + serverTimestamp: timestamp, + receivedAt, + isPublic: true, + message: { + body: adnMessage.text, + attachments: [], + group: { + id: this.conversationId, + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, + flags: 0, + expireTimer: 0, + profileKey: null, + timestamp, + received_at: receivedAt, + sent_at: timestamp, + quote: null, + contact: [], + preview: [], + profile: { + displayName: from, + }, + }, + }; + receivedAt += 1; // Ensure different arrival times + + this.serverAPI.chatAPI.emit('publicMessage', { + message: messageData, + }); + this.lastGot = !this.lastGot + ? adnMessage.id + : Math.max(this.lastGot, adnMessage.id); + }); + conversation.setLastRetrievedMessage(this.lastGot); + } + + setTimeout(() => { + this.pollForMessages(); + }, GROUPCHAT_POLL_EVERY); + } + + // create a message in the channel + async sendMessage(text, messageTimeStamp, displayName, pubKey) { + const payload = { + text, + annotations: [ + { + type: 'network.loki.messenger.publicChat', + value: { + timestamp: messageTimeStamp, + // will deprecated + from: displayName, + // will deprecated + source: pubKey, + }, + }, + ], + }; + const res = await this.serverRequest(`${this.baseChannelUrl}/messages`, { + method: 'POST', + objBody: payload, + }); + if (!res.err && res.response) { + return res.response.data.id; + } + return false; + } +} + +module.exports = LokiPublicChatAPI; diff --git a/js/modules/loki_rss_api.js b/js/modules/loki_rss_api.js new file mode 100644 index 000000000..ee5486d53 --- /dev/null +++ b/js/modules/loki_rss_api.js @@ -0,0 +1,137 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-loop-func */ +/* global log, window, textsecure */ + +const EventEmitter = require('events'); +const nodeFetch = require('node-fetch'); + +const PER_MIN = 60 * 1000; +const PER_HR = 60 * PER_MIN; +const RSS_POLL_EVERY = 1 * PER_HR; // once an hour + +function xml2json(xml) { + try { + let obj = {}; + if (xml.children.length > 0) { + for (let i = 0; i < xml.children.length; i += 1) { + const item = xml.children.item(i); + const { nodeName } = item; + + if (typeof obj[nodeName] === 'undefined') { + obj[nodeName] = xml2json(item); + } else { + if (typeof obj[nodeName].push === 'undefined') { + const old = obj[nodeName]; + + obj[nodeName] = []; + obj[nodeName].push(old); + } + obj[nodeName].push(xml2json(item)); + } + } + } else { + obj = xml.textContent; + } + return obj; + } catch (e) { + log.error(e.message); + } + return {}; +} + +class LokiRssAPI extends EventEmitter { + constructor(settings) { + super(); + // properties + this.feedUrl = settings.RSS_FEED; + this.groupId = settings.CONVO_ID; + this.feedTitle = settings.title; + this.closeable = settings.closeable; + // non configureable options + this.feedTimer = null; + // initial set up + this.getFeed(); + } + + async getFeed() { + let response; + let success = true; + try { + response = await nodeFetch(this.feedUrl); + } catch (e) { + log.error('fetcherror', e); + success = false; + } + const responseXML = await response.text(); + let feedDOM = {}; + try { + feedDOM = await new window.DOMParser().parseFromString( + responseXML, + 'text/xml' + ); + } catch (e) { + log.error('xmlerror', e); + success = false; + } + if (!success) return; + const feedObj = xml2json(feedDOM); + let receivedAt = new Date().getTime(); + + if (!feedObj || !feedObj.rss || !feedObj.rss.channel) { + log.error('rsserror', feedObj, feedDOM, responseXML); + return; + } + if (!feedObj.rss.channel.item) { + // no records + return; + } + if (feedObj.rss.channel.item.constructor !== Array) { + // Treat single record as array for consistency + feedObj.rss.channel.item = [feedObj.rss.channel.item]; + } + feedObj.rss.channel.item.reverse().forEach(item => { + // log.debug('item', item) + + const pubDate = new Date(item.pubDate); + + // if we use group style, we can put the title in the source + const messageData = { + friendRequest: false, + source: this.groupId, + sourceDevice: 1, + timestamp: pubDate.getTime(), + serverTimestamp: pubDate.getTime(), + receivedAt, + isRss: true, + message: { + body: `

${item.title}

${item.description}`, + attachments: [], + group: { + id: this.groupId, + type: textsecure.protobuf.GroupContext.Type.DELIVER, + }, + flags: 0, + expireTimer: 0, + profileKey: null, + timestamp: pubDate.getTime(), + received_at: receivedAt, + sent_at: pubDate.getTime(), + quote: null, + contact: [], + preview: [], + profile: null, + }, + }; + receivedAt += 1; // Ensure different arrival times + this.emit('rssMessage', { + message: messageData, + }); + }); + function callTimer() { + this.getFeed(); + } + this.feedTimer = setTimeout(callTimer, RSS_POLL_EVERY); + } +} + +module.exports = LokiRssAPI; diff --git a/js/modules/loki_snode_api.js b/js/modules/loki_snode_api.js index 49e00c773..d46b2755d 100644 --- a/js/modules/loki_snode_api.js +++ b/js/modules/loki_snode_api.js @@ -89,6 +89,7 @@ class LokiSnodeAPI { async initialiseRandomPool(seedNodes = [...window.seedNodeList]) { const params = { limit: 20, + active_only: true, fields: { public_ip: true, storage_port: true, diff --git a/js/views/.inbox_view.js.swp b/js/views/.inbox_view.js.swp deleted file mode 100644 index 460fcffa5..000000000 Binary files a/js/views/.inbox_view.js.swp and /dev/null differ diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 4309a79e5..055a829a6 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -201,6 +201,7 @@ isVerified: this.model.isVerified(), isKeysPending: !this.model.isFriend(), isMe: this.model.isMe(), + isClosable: this.model.isClosable(), isBlocked: this.model.isBlocked(), isGroup: !this.model.isPrivate(), isOnline: this.model.isOnline(), @@ -1290,6 +1291,29 @@ }, deleteMessage(message) { + if (this.model.isPublic()) { + const dialog = new Whisper.ConfirmationDialogView({ + message: i18n('deletePublicWarning'), + okText: i18n('delete'), + resolve: async () => { + const success = await this.model.deletePublicMessage(message); + if (!success) { + // Message failed to delete from server, show error? + return; + } + await window.Signal.Data.removeMessage(message.id, { + Message: Whisper.Message, + }); + message.trigger('unload'); + this.resetPanel(); + this.updateHeader(); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + return; + } const dialog = new Whisper.ConfirmationDialogView({ message: i18n('deleteWarning'), okText: i18n('delete'), diff --git a/libloki/crypto.js b/libloki/crypto.js index 7a0c7a271..f8ad8d7f2 100644 --- a/libloki/crypto.js +++ b/libloki/crypto.js @@ -34,8 +34,8 @@ async function DHDecrypt(symmetricKey, ivAndCiphertext) { const iv = ivAndCiphertext.slice(0, IV_LENGTH); - const cipherText = ivAndCiphertext.slice(IV_LENGTH); - return libsignal.crypto.decrypt(symmetricKey, cipherText, iv); + const ciphertext = ivAndCiphertext.slice(IV_LENGTH); + return libsignal.crypto.decrypt(symmetricKey, ciphertext, iv); } class FallBackSessionCipher { @@ -131,18 +131,18 @@ return this._ephemeralPubKeyHex; } - async decrypt(snodeAddress, ivAndCipherTextBase64) { - const ivAndCipherText = dcodeIO.ByteBuffer.wrap( - ivAndCipherTextBase64, + async decrypt(snodeAddress, ivAndCiphertextBase64) { + const ivAndCiphertext = dcodeIO.ByteBuffer.wrap( + ivAndCiphertextBase64, 'base64' ).toArrayBuffer(); const symmetricKey = await this._getSymmetricKey(snodeAddress); try { - const decrypted = await DHDecrypt(symmetricKey, ivAndCipherText); + const decrypted = await DHDecrypt(symmetricKey, ivAndCiphertext); const decoder = new TextDecoder(); return decoder.decode(decrypted); } catch (e) { - return ivAndCipherText; + return ivAndCiphertext; } } @@ -153,11 +153,31 @@ plainText = textEncoder.encode(plainText); } const symmetricKey = await this._getSymmetricKey(snodeAddress); - const cipherText = await DHEncrypt(symmetricKey, plainText); - return dcodeIO.ByteBuffer.wrap(cipherText).toString('base64'); + const ciphertext = await DHEncrypt(symmetricKey, plainText); + return dcodeIO.ByteBuffer.wrap(ciphertext).toString('base64'); } } + async function decryptToken({ cipherText64, serverPubKey64 }) { + const ivAndCiphertext = new Uint8Array( + dcodeIO.ByteBuffer.fromBase64(cipherText64).toArrayBuffer() + ); + + const serverPubKey = new Uint8Array( + dcodeIO.ByteBuffer.fromBase64(serverPubKey64).toArrayBuffer() + ); + const { privKey } = await textsecure.storage.protocol.getIdentityKeyPair(); + const symmetricKey = libsignal.Curve.calculateAgreement( + serverPubKey, + privKey + ); + + const token = await DHDecrypt(symmetricKey, ivAndCiphertext); + + const tokenString = dcodeIO.ByteBuffer.wrap(token).toString('utf8'); + return tokenString; + } + const snodeCipher = new LokiSnodeChannel(); window.libloki.crypto = { @@ -166,6 +186,7 @@ FallBackSessionCipher, FallBackDecryptionError, snodeCipher, + decryptToken, // for testing _LokiSnodeChannel: LokiSnodeChannel, _decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey, diff --git a/libtextsecure/errors.js b/libtextsecure/errors.js index c97d21fe3..0343a836f 100644 --- a/libtextsecure/errors.js +++ b/libtextsecure/errors.js @@ -263,6 +263,16 @@ } } + function PublicTokenError(message) { + this.name = 'PublicTokenError'; + + ReplayableError.call(this, { + name: 'PublicTokenError', + message, + }); + } + inherit(ReplayableError, PublicTokenError); + function TimestampError(message) { this.name = 'TimeStampError'; @@ -273,6 +283,18 @@ } inherit(ReplayableError, TimestampError); + function PublicChatError(message) { + this.name = 'PublicChatError'; + this.message = message; + Error.call(this, message); + + // 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; @@ -292,4 +314,6 @@ window.textsecure.WrongSwarmError = WrongSwarmError; window.textsecure.WrongDifficultyError = WrongDifficultyError; window.textsecure.TimestampError = TimestampError; + window.textsecure.PublicChatError = PublicChatError; + window.textsecure.PublicTokenError = PublicTokenError; })(); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 9c356df08..a65955bed 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -13,9 +13,11 @@ /* global GroupBuffer: false */ /* global WebSocketResource: false */ /* global localLokiServer: false */ +/* global lokiPublicChatAPI: false */ /* global localServerPort: false */ /* global lokiMessageAPI: false */ /* global lokiP2pAPI: false */ +/* global feeds: false */ /* eslint-disable more/no-then */ /* eslint-disable no-unreachable */ @@ -75,6 +77,14 @@ MessageReceiver.prototype.extend({ }); this.httpPollingResource.pollServer(); localLokiServer.on('message', this.handleP2pMessage.bind(this)); + lokiPublicChatAPI.on( + 'publicMessage', + this.handleUnencryptedMessage.bind(this) + ); + // set up pollers for any RSS feeds + feeds.forEach(feed => { + feed.on('rssMessage', this.handleUnencryptedMessage.bind(this)); + }); this.startLocalServer(); // TODO: Rework this socket stuff to work with online messaging @@ -142,6 +152,12 @@ MessageReceiver.prototype.extend({ }; this.httpPollingResource.handleMessage(message, options); }, + handleUnencryptedMessage({ message }) { + const ev = new Event('message'); + ev.confirm = function confirmTerm() {}; + ev.data = message; + this.dispatchAndWait(ev); + }, stopProcessing() { window.log.info('MessageReceiver: stopProcessing requested'); this.stoppingProcessing = true; diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index ad12ee51a..ca4142313 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -43,9 +43,19 @@ function OutgoingMessage( this.failoverNumbers = []; this.unidentifiedDeliveries = []; - const { numberInfo, senderCertificate, online, messageType, isPing } = + const { + numberInfo, + senderCertificate, + online, + messageType, + isPing, + isPublic, + publicSendData, + } = options || {}; this.numberInfo = numberInfo; + this.isPublic = isPublic; + this.publicSendData = publicSendData; this.senderCertificate = senderCertificate; this.online = online; this.messageType = messageType || 'outgoing'; @@ -193,6 +203,10 @@ OutgoingMessage.prototype = { numConnections: NUM_SEND_CONNECTIONS, isPing: this.isPing, }; + options.isPublic = this.isPublic; + if (this.isPublic) { + options.publicSendData = this.publicSendData; + } await lokiMessageAPI.sendMessage(pubKey, data, timestamp, ttl, options); } catch (e) { if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { @@ -259,6 +273,21 @@ OutgoingMessage.prototype = { }, doSendMessage(number, deviceIds, recurse) { const ciphers = {}; + if (this.isPublic) { + return this.transmitMessage( + number, + this.message.dataMessage, + this.timestamp, + 0 // ttl + ) + .then(() => { + this.successfulNumbers[this.successfulNumbers.length] = number; + this.numberCompleted(); + }) + .catch(error => { + throw error; + }); + } /* Disabled because i'm not sure how senderCertificate works :thinking: const { numberInfo, senderCertificate } = this; diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index c2be038d8..b5b608f20 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -942,7 +942,11 @@ MessageSender.prototype = { options ) { const me = textsecure.storage.user.getNumber(); - const numbers = groupNumbers.filter(number => number !== me); + let numbers = groupNumbers.filter(number => number !== me); + if (options.isPublic) { + numbers = [groupId]; + } + const profile = textsecure.storage.impl.getLocalProfile(); const attrs = { recipients: numbers, body: messageText, @@ -953,6 +957,7 @@ MessageSender.prototype = { needsSync: true, expireTimer, profileKey, + profile, group: { id: groupId, type: textsecure.protobuf.GroupContext.Type.DELIVER, diff --git a/package.json b/package.json index 6557522b9..2dad870a6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build", "generate": "yarn icon-gen && yarn grunt", "build": "build --config.extraMetadata.environment=$SIGNAL_ENV", - "build-release": "SIGNAL_ENV=production && npm run build -- --config.directories.output=release", + "build-release": "export SIGNAL_ENV=production && npm run build -- --config.directories.output=release", "sign-release": "node ts/updater/generateSignature.js", "build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js", "clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js", diff --git a/preload.js b/preload.js index be63b33f6..54577110a 100644 --- a/preload.js +++ b/preload.js @@ -324,6 +324,10 @@ window.LokiP2pAPI = require('./js/modules/loki_p2p_api'); window.LokiMessageAPI = require('./js/modules/loki_message_api'); +window.LokiPublicChatAPI = require('./js/modules/loki_public_chat_api'); + +window.LokiRssAPI = require('./js/modules/loki_rss_api'); + window.LocalLokiServer = require('./libloki/modules/local_loki_server'); window.localServerPort = config.localServerPort; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4a011d833..ee97bdf7a 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -517,10 +517,20 @@ color: $color-gray-60; text-transform: uppercase; } +.module-message__metadata__public { + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + color: $color-gray-60; + text-transform: uppercase; +} .module-message__metadata__date--incoming, .module-message__metadata__p2p--incoming { color: $color-white-08; } +.module-message__metadata__public--incoming { + color: $color-white-08; +} .module-message__metadata__date--with-image-no-caption { color: $color-white; } @@ -528,6 +538,9 @@ .module-message__metadata__p2p { font-weight: bold; } +.module-message__metadata__public { + font-weight: bold; +} .module-message__metadata__spacer { flex-grow: 1; diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index c158a5372..f14d01856 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -21,6 +21,7 @@ export type PropsData = { type: 'group' | 'direct'; avatarPath?: string; isMe: boolean; + isClosable?: boolean; lastUpdated: number; unreadCount: number; @@ -30,6 +31,7 @@ export type PropsData = { lastMessage?: { status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; text: string; + isRss: boolean; }; showFriendRequestIndicator?: boolean; @@ -162,6 +164,7 @@ export class ConversationListItem extends React.PureComponent { i18n, isBlocked, isMe, + isClosable, hasNickname, onDeleteContact, onDeleteMessages, @@ -190,7 +193,7 @@ export class ConversationListItem extends React.PureComponent { ) : null} {i18n('copyPublicKey')} {i18n('deleteMessages')} - {!isMe ? ( + {!isMe && isClosable ? ( {i18n('deleteContact')} ) : null} @@ -213,7 +216,13 @@ export class ConversationListItem extends React.PureComponent { if (!lastMessage && !isTyping) { return null; } - const text = lastMessage && lastMessage.text ? lastMessage.text : ''; + let text = lastMessage && lastMessage.text ? lastMessage.text : ''; + + // if coming from Rss feed + if (lastMessage && lastMessage.isRss) { + // strip any HTML + text = text.replace(/<[^>]*>?/gm, ''); + } if (isEmpty(text)) { return null; diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index d2d14d7a8..6f9a4998e 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -10,17 +10,30 @@ interface Props { profileName?: string; i18n: LocalizerType; module?: string; + boldProfileName?: Boolean; } export class ContactName extends React.Component { public render() { - const { phoneNumber, name, profileName, i18n, module } = this.props; + const { + phoneNumber, + name, + profileName, + i18n, + module, + boldProfileName, + } = this.props; const prefix = module ? module : 'module-contact-name'; const title = name ? name : phoneNumber; const shouldShowProfile = Boolean(profileName && !name); + const styles = (boldProfileName + ? { + fontWeight: 'bold', + } + : {}) as React.CSSProperties; const profileElement = shouldShowProfile ? ( - + ) : null; diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 096ca3ecd..dfc10c197 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -26,6 +26,7 @@ interface Props { isVerified: boolean; isMe: boolean; + isClosable?: boolean; isGroup: boolean; isArchived: boolean; @@ -96,7 +97,14 @@ export class ConversationHeader extends React.Component { } public renderTitle() { - const { phoneNumber, i18n, profileName, isKeysPending, isMe } = this.props; + const { + phoneNumber, + i18n, + profileName, + isKeysPending, + isMe, + name, + } = this.props; if (isMe) { return ( @@ -111,6 +119,7 @@ export class ConversationHeader extends React.Component { {isKeysPending ? '(pending)' : null} @@ -193,6 +202,7 @@ export class ConversationHeader extends React.Component { i18n, isBlocked, isMe, + isClosable, isGroup, isArchived, onDeleteMessages, @@ -267,7 +277,7 @@ export class ConversationHeader extends React.Component { {i18n('archiveConversation')} )} {i18n('deleteMessages')} - {!isMe ? ( + {!isMe && isClosable ? ( {i18n('deleteContact')} ) : null} diff --git a/ts/components/conversation/Linkify.tsx b/ts/components/conversation/Linkify.tsx index 200a156bc..6562b6ed8 100644 --- a/ts/components/conversation/Linkify.tsx +++ b/ts/components/conversation/Linkify.tsx @@ -9,6 +9,7 @@ const linkify = LinkifyIt(); interface Props { text: string; + isRss?: boolean; /** Allows you to customize now non-links are rendered. Simplest is just a . */ renderNonLink?: RenderTextCallbackType; } @@ -22,12 +23,32 @@ export class Linkify extends React.Component { }; public render() { - const { text, renderNonLink } = this.props; - const matchData = linkify.match(text) || []; + const { text, renderNonLink, isRss } = this.props; const results: Array = []; - let last = 0; let count = 1; + if (isRss && text.indexOf(')<[^<]*)*<\/script>/gi, + '' + ) + .replace(/)<[^<]*)*<\/style>/gi, ''), + }} + /> + ); + // should already have links + + return results; + } + + const matchData = linkify.match(text) || []; + let last = 0; + // We have to do this, because renderNonLink is not required in our Props object, // but it is always provided via defaultProps. if (!renderNonLink) { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 8c29c60fe..8ed5fa4f3 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -48,6 +48,7 @@ interface LinkPreviewType { export interface Props { disableMenu?: boolean; + isDeletable: boolean; text?: string; textPending?: boolean; id?: string; @@ -86,6 +87,8 @@ export interface Props { expirationLength?: number; expirationTimestamp?: number; isP2p?: boolean; + isPublic?: boolean; + isRss?: boolean; onClickAttachment?: (attachment: AttachmentType) => void; onClickLinkPreview?: (url: string) => void; @@ -94,6 +97,7 @@ export interface Props { onRetrySend?: () => void; onDownload?: (isDangerous: boolean) => void; onDelete?: () => void; + onCopyPubKey?: () => void; onShowDetail: () => void; } @@ -203,6 +207,7 @@ export class Message extends React.PureComponent { textPending, timestamp, isP2p, + isPublic, } = this.props; if (collapseMetadata) { @@ -212,6 +217,9 @@ export class Message extends React.PureComponent { const isShowingImage = this.isShowingImage(); const withImageNoCaption = Boolean(!text && isShowingImage); const showError = status === 'error' && direction === 'outgoing'; + const hasBadge = isP2p || isPublic; + const badgeText = isPublic ? 'Public' : isP2p ? 'P2p' : ''; + const badgeType = badgeText.toLowerCase(); return (
{ module="module-message__metadata__date" /> )} - {isP2p ? ( + {hasBadge ? ( -  • P2P +  • {badgeText} ) : null} {expirationLength && expirationTimestamp ? ( @@ -302,11 +310,14 @@ export class Message extends React.PureComponent { return (
); @@ -668,7 +679,7 @@ export class Message extends React.PureComponent { } public renderText() { - const { text, textPending, i18n, direction, status } = this.props; + const { text, textPending, i18n, direction, status, isRss } = this.props; const contents = direction === 'incoming' && status === 'error' @@ -692,6 +703,7 @@ export class Message extends React.PureComponent { > @@ -809,11 +821,14 @@ export class Message extends React.PureComponent { onCopyText, direction, status, + isDeletable, onDelete, onDownload, onReply, onRetrySend, onShowDetail, + onCopyPubKey, + isPublic, i18n, } = this.props; @@ -866,14 +881,19 @@ export class Message extends React.PureComponent { {i18n('retrySend')} ) : null} - - {i18n('deleteMessage')} - + {isDeletable ? ( + + {i18n('deleteMessage')} + + ) : null} + {isPublic ? ( + {i18n('copyPublicKey')} + ) : null} ); } diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx index fa04036c9..f9a750f32 100644 --- a/ts/components/conversation/MessageBody.tsx +++ b/ts/components/conversation/MessageBody.tsx @@ -9,6 +9,7 @@ import { LocalizerType, RenderTextCallbackType } from '../../types/Util'; interface Props { text: string; + isRss?: boolean; textPending?: boolean; /** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */ disableJumbomoji?: boolean; @@ -73,6 +74,7 @@ export class MessageBody extends React.Component { textPending, disableJumbomoji, disableLinks, + isRss, i18n, } = this.props; const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); @@ -93,6 +95,7 @@ export class MessageBody extends React.Component { return this.addDownloading( { return renderEmoji({ i18n, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 21e1072f6..77379eb03 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -40,10 +40,12 @@ export type ConversationType = { lastMessage?: { status: 'error' | 'sending' | 'sent' | 'delivered' | 'read'; text: string; + isRss: boolean; }; phoneNumber: string; type: 'direct' | 'group'; isMe: boolean; + isClosable?: boolean; lastUpdated: number; unreadCount: number; isSelected: boolean; diff --git a/tslint.json b/tslint.json index 50536f021..d875d9f6a 100644 --- a/tslint.json +++ b/tslint.json @@ -136,7 +136,15 @@ // 'as' is nicer than angle brackets. "prefer-type-cast": false, // We use || and && shortcutting because we're javascript programmers - "strict-boolean-expressions": false + "strict-boolean-expressions": false, + "react-no-dangerous-html": [ + true, + { + "file": "ts/components/conversation/Linkify.tsx", + "method": "render", + "comment": "Usage has been approved by Ryan Tharp on 2019-07-22" + } + ] }, "rulesDirectory": ["node_modules/tslint-microsoft-contrib"] }