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: `

${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/views/conversation_view.js b/js/views/conversation_view.js index 4309a79e5..edea0036d 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(), diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index db640c623..a65955bed 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -17,6 +17,7 @@ /* global localServerPort: false */ /* global lokiMessageAPI: false */ /* global lokiP2pAPI: false */ +/* global feeds: false */ /* eslint-disable more/no-then */ /* eslint-disable no-unreachable */ @@ -76,7 +77,14 @@ MessageReceiver.prototype.extend({ }); this.httpPollingResource.pollServer(); localLokiServer.on('message', this.handleP2pMessage.bind(this)); - lokiPublicChatAPI.on('publicMessage', this.handlePublicMessage.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 @@ -144,7 +152,7 @@ MessageReceiver.prototype.extend({ }; this.httpPollingResource.handleMessage(message, options); }, - handlePublicMessage({ message }) { + handleUnencryptedMessage({ message }) { const ev = new Event('message'); ev.confirm = function confirmTerm() {}; ev.data = message; diff --git a/preload.js b/preload.js index 7ff52dc3d..54577110a 100644 --- a/preload.js +++ b/preload.js @@ -326,6 +326,8 @@ 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/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/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index b1497e21c..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; @@ -201,6 +202,7 @@ export class ConversationHeader extends React.Component { i18n, isBlocked, isMe, + isClosable, isGroup, isArchived, onDeleteMessages, @@ -275,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..0f27953d7 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,9 +23,28 @@ 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 = []; + + if (isRss && text.indexOf(')<[^<]*)*<\/script>/gi, + '' + ) + .replace(/)<[^<]*)*<\/style>/gi, ''), + }} + /> + ); + // should already have links + + return results; + } + + const matchData = linkify.match(text) || []; let last = 0; let count = 1; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 764aa397b..e9489ed96 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -87,6 +87,7 @@ export interface Props { expirationTimestamp?: number; isP2p?: boolean; isPublic?: boolean; + isRss?: boolean; onClickAttachment?: (attachment: AttachmentType) => void; onClickLinkPreview?: (url: string) => void; @@ -676,7 +677,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' @@ -700,6 +701,7 @@ export class Message extends React.PureComponent { > 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"] }