diff --git a/js/background.js b/js/background.js index 1b2317c80..13ae7d9c3 100644 --- a/js/background.js +++ b/js/background.js @@ -228,6 +228,7 @@ window.log.warn(`Could not set up channel for ${conversation.id}`); } }); + window.lokiRssAPI = new window.LokiRssAPI(); window.lokiP2pAPI = new window.LokiP2pAPI(ourKey); window.lokiP2pAPI.on('pingContact', pubKey => { const isPing = true; @@ -1424,6 +1425,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..d5b0c31bb 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -196,6 +196,9 @@ isPublic() { return this.id.match(/^publicChat:/); }, + isRss() { + return this.id.match(/^rss:/); + }, isBlocked() { return BlockedNumberController.isBlocked(this.id); }, @@ -453,6 +456,7 @@ lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), + isRss: this.isRss(), }, isOnline: this.isOnline(), hasNickname: !!this.getNickname(), @@ -642,6 +646,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: @@ -2088,6 +2097,20 @@ }); } }, + 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/loki_rss_api.js b/js/modules/loki_rss_api.js new file mode 100644 index 000000000..1138d8ffe --- /dev/null +++ b/js/modules/loki_rss_api.js @@ -0,0 +1,163 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-loop-func */ +/* global log, window, textsecure, ConversationController */ + +const EventEmitter = require('events'); +const nodeFetch = require('node-fetch'); + +const RSS_FEED = 'https://loki.network/feed/'; +const CONVO_ID = 'rss://loki.network/feed/'; +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.nodeName; + + 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 {}; +} + +// hate duplicating this here... +const friendRequestStatusEnum = Object.freeze({ + // New conversation, no messages sent or received + none: 0, + // This state is used to lock the input early while sending + pendingSend: 1, + // Friend request sent, awaiting response + requestSent: 2, + // Friend request received, awaiting user input + requestReceived: 3, + // We did it! + friends: 4, + // Friend Request sent but timed out + requestExpired: 5, +}); + +class LokiRssAPI extends EventEmitter { + constructor() { + super(); + // properties + this.groupId = CONVO_ID; + this.feedTimer = null; + this.conversationSetup = false; + // initial set up + this.getFeed(); + } + + setupConversation() { + // only run once + if (this.conversationSetup) return; + // wait until conversations are loaded + if (ConversationController._initialFetchComplete) { + const conversation = ConversationController.getOrCreate( + this.groupId, + 'group' + ); + conversation.setFriendRequestStatus(friendRequestStatusEnum.friends); + conversation.setGroupNameAndAvatar( + 'Loki.network News', + 'images/loki/loki_icon.png' + ); + conversation.updateTextInputState(); + this.conversationSetup = true; // prevent running again + } + } + + async getFeed() { + let response; + let success = true; + try { + response = await nodeFetch(RSS_FEED); + } 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(); + + // make sure conversation is set up properly + // (delay to after the network response intentionally) + this.setupConversation(); + + 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/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index db640c623..42312b596 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 lokiRssAPI: false */ /* eslint-disable more/no-then */ /* eslint-disable no-unreachable */ @@ -77,6 +78,7 @@ MessageReceiver.prototype.extend({ this.httpPollingResource.pollServer(); localLokiServer.on('message', this.handleP2pMessage.bind(this)); lokiPublicChatAPI.on('publicMessage', this.handlePublicMessage.bind(this)); + lokiRssAPI.on('rssMessage', this.handlePublicMessage.bind(this)); this.startLocalServer(); // TODO: Rework this socket stuff to work with online messaging 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..60870f6b3 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -30,6 +30,7 @@ export type PropsData = { lastMessage?: { status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; text: string; + isRss: boolean; }; showFriendRequestIndicator?: boolean; @@ -213,7 +214,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/Linkify.tsx b/ts/components/conversation/Linkify.tsx index 200a156bc..975ffd668 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,25 @@ 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(' + ); + // 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..470c5bd2d 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -40,6 +40,7 @@ export type ConversationType = { lastMessage?: { status: 'error' | 'sending' | 'sent' | 'delivered' | 'read'; text: string; + isRss: boolean; }; phoneNumber: string; type: 'direct' | 'group'; 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"] }