diff --git a/app/sql.js b/app/sql.js index 64fa1848a..23fd7d40e 100644 --- a/app/sql.js +++ b/app/sql.js @@ -97,6 +97,7 @@ module.exports = { updateConversation, removeConversation, getAllConversations, + getAllRssFeedConversations, getAllPublicConversations, getPubKeysWithFriendStatus, getAllConversationIds, @@ -784,57 +785,84 @@ async function updateToLokiSchemaVersion1(currentVersion, instance) { console.log('updateToLokiSchemaVersion1: starting...'); await instance.run('BEGIN TRANSACTION;'); - const publicChatData = { - id: 'publicChat:1@chat.lokinet.org', + await instance.run( + `ALTER TABLE messages + ADD COLUMN serverId STRING;` + ); + + 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 baseData = { friendRequestStatus: 4, // Friends sealedSender: 0, sessionResetStatus: 0, swarmNodes: [], type: 'group', - server: 'https://chat.lokinet.org', - name: 'Loki Public Chat', - channelId: '1', unlockTimestamp: null, unreadCount: 0, verified: 0, version: 2, }; - const { id, type, name, friendRequestStatus } = publicChatData; - - await instance.run( - `ALTER TABLE messages - ADD COLUMN serverId STRING;` - ); - - await instance.run( - `INSERT INTO conversations ( - id, - json, + const publicChatData = { + ...baseData, + id: 'publicChat:1@chat.lokinet.org', + server: 'https://chat.lokinet.org', + name: 'Loki Public Chat', + channelId: '1', + }; - type, - members, - name, - friendRequestStatus - ) values ( - $id, - $json, + 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', + }; - $type, - $members, - $name, - $friendRequestStatus - );`, - { - $id: id, - $json: objectToJSON(publicChatData), + 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', + }; - $type: type, - $members: null, - $name: name, - $friendRequestStatus: friendRequestStatus, - } - ); + await initConversation(publicChatData); + await initConversation(newsRssFeedData); + await initConversation(updatesRssFeedData); await instance.run( `INSERT INTO loki_schema ( @@ -1606,6 +1634,17 @@ 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 diff --git a/js/background.js b/js/background.js index 1b2317c80..141218b5f 100644 --- a/js/background.js +++ b/js/background.js @@ -206,6 +206,15 @@ const initAPIs = async () => { const ourKey = textsecure.storage.user.getNumber(); + const rssFeedConversations = await window.Signal.Data.getAllRssFeedConversations( + { + ConversationCollection: Whisper.ConversationCollection, + } + ); + window.feeds = []; + rssFeedConversations.forEach(conversation => { + window.feeds.push(new window.LokiRssAPI(conversation.getRssSettings())); + }); window.lokiMessageAPI = new window.LokiMessageAPI(ourKey); window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey); const publicConversations = await window.Signal.Data.getAllPublicConversations( @@ -577,7 +586,7 @@ 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(); @@ -591,7 +600,7 @@ // logger: window.log, // }); - initAPIs(); + await initAPIs(); connect(true); }); @@ -1424,6 +1433,7 @@ unread: 1, isP2p: data.isP2p, isPublic: data.isPublic, + isRss: data.isRss, }; if (data.friendRequest) { diff --git a/js/models/conversations.js b/js/models/conversations.js index 26a27e8a8..aec65b55d 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -196,6 +196,12 @@ 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); }, @@ -302,6 +308,7 @@ }, async updateProfileAvatar() { + if (this.isRss()) return; const path = profileImages.getOrCreateImagePath(this.id); await this.setProfileAvatar(path); }, @@ -437,6 +444,7 @@ color, type: this.isPrivate() ? 'direct' : 'group', isMe: this.isMe(), + isClosable: this.isClosable(), isTyping: typingKeys.length > 0, lastUpdated: this.get('timestamp'), name: this.getName(), @@ -453,6 +461,7 @@ lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), + isRss: this.isRss(), }, isOnline: this.isOnline(), hasNickname: !!this.getNickname(), @@ -642,6 +651,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: @@ -2031,6 +2045,17 @@ 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()) { @@ -2088,6 +2113,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) { diff --git a/js/models/messages.js b/js/models/messages.js index 6aee70a89..b79307473 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -674,6 +674,7 @@ expirationTimestamp, isP2p: !!this.get('isP2p'), isPublic: !!this.get('isPublic'), + isRss: !!this.get('isRss'), onCopyText: () => this.copyText(), onReply: () => this.trigger('reply', this), diff --git a/js/modules/data.js b/js/modules/data.js index cebc102cb..36be09d6f 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -118,6 +118,7 @@ module.exports = { getPubKeysWithFriendStatus, getAllConversationIds, getAllPrivateConversations, + getAllRssFeedConversations, getAllPublicConversations, getAllGroupsInvolvingId, @@ -741,6 +742,14 @@ 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(); diff --git a/js/modules/loki_rss_api.js b/js/modules/loki_rss_api.js new file mode 100644 index 000000000..a6f446dbe --- /dev/null +++ b/js/modules/loki_rss_api.js @@ -0,0 +1,134 @@ +/* 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; + this.conversationSetup = false; + // 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; + } + 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: `