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('') !== -1) {
+ results.push(
+
+ );
+ // 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"]
}