Merge pull request #463 from loki-project/public-chat

Public chat
pull/465/head
Beaudan Campbell-Brown 6 years ago committed by GitHub
commit 43bd167965
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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."

@ -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,

@ -4,7 +4,7 @@
"cdnUrl": "random.snode",
"contentProxyUrl": "",
"localServerPort": "8081",
"defaultPoWDifficulty": "100",
"defaultPoWDifficulty": "1",
"seedNodeList": [
{
"ip": "storage.testnetseed1.loki.network",

@ -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) {

@ -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(

@ -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'),

@ -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;

@ -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) {

@ -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,

@ -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;

@ -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: `<h2>${item.title} </h2>${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;

@ -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,

Binary file not shown.

@ -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'),

@ -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,

@ -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;
})();

@ -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;

@ -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;

@ -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,

@ -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",

@ -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;

@ -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;

@ -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<Props> {
i18n,
isBlocked,
isMe,
isClosable,
hasNickname,
onDeleteContact,
onDeleteMessages,
@ -190,7 +193,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
) : null}
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem>
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{!isMe ? (
{!isMe && isClosable ? (
<MenuItem onClick={onDeleteContact}>{i18n('deleteContact')}</MenuItem>
) : null}
</ContextMenu>
@ -213,7 +216,13 @@ export class ConversationListItem extends React.PureComponent<Props> {
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;

@ -10,17 +10,30 @@ interface Props {
profileName?: string;
i18n: LocalizerType;
module?: string;
boldProfileName?: Boolean;
}
export class ContactName extends React.Component<Props> {
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 ? (
<span className={`${prefix}__profile-name`}>
<span style={styles} className={`${prefix}__profile-name`}>
<Emojify text={profileName || ''} i18n={i18n} />
</span>
) : null;

@ -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<Props> {
}
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<Props> {
<ContactName
phoneNumber={phoneNumber}
profileName={profileName}
name={name}
i18n={i18n}
/>
{isKeysPending ? '(pending)' : null}
@ -193,6 +202,7 @@ export class ConversationHeader extends React.Component<Props> {
i18n,
isBlocked,
isMe,
isClosable,
isGroup,
isArchived,
onDeleteMessages,
@ -267,7 +277,7 @@ export class ConversationHeader extends React.Component<Props> {
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{!isMe ? (
{!isMe && isClosable ? (
<MenuItem onClick={onDeleteContact}>{i18n('deleteContact')}</MenuItem>
) : null}
</ContextMenu>

@ -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 <span>. */
renderNonLink?: RenderTextCallbackType;
}
@ -22,12 +23,32 @@ export class Linkify extends React.Component<Props> {
};
public render() {
const { text, renderNonLink } = this.props;
const matchData = linkify.match(text) || [];
const { text, renderNonLink, isRss } = this.props;
const results: Array<any> = [];
let last = 0;
let count = 1;
if (isRss && text.indexOf('</') !== -1) {
results.push(
<div
key={count++}
dangerouslySetInnerHTML={{
__html: text
.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
''
)
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/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) {

@ -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<Props, State> {
textPending,
timestamp,
isP2p,
isPublic,
} = this.props;
if (collapseMetadata) {
@ -212,6 +217,9 @@ export class Message extends React.PureComponent<Props, State> {
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 (
<div
@ -244,14 +252,14 @@ export class Message extends React.PureComponent<Props, State> {
module="module-message__metadata__date"
/>
)}
{isP2p ? (
{hasBadge ? (
<span
className={classNames(
'module-message__metadata__p2p',
`module-message__metadata__p2p--${direction}`
`module-message__metadata__${badgeType}`,
`module-message__metadata__${badgeType}--${direction}`
)}
>
&nbsp;&nbsp;P2P
&nbsp;&nbsp;{badgeText}
</span>
) : null}
{expirationLength && expirationTimestamp ? (
@ -302,11 +310,14 @@ export class Message extends React.PureComponent<Props, State> {
return (
<div className="module-message__author">
<ContactName
phoneNumber={authorPhoneNumber}
phoneNumber={`(...${authorPhoneNumber.substring(
authorPhoneNumber.length - 6
)})`}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
i18n={i18n}
boldProfileName={true}
/>
</div>
);
@ -668,7 +679,7 @@ export class Message extends React.PureComponent<Props, State> {
}
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<Props, State> {
>
<MessageBody
text={contents || ''}
isRss={isRss}
i18n={i18n}
textPending={textPending}
/>
@ -809,11 +821,14 @@ export class Message extends React.PureComponent<Props, State> {
onCopyText,
direction,
status,
isDeletable,
onDelete,
onDownload,
onReply,
onRetrySend,
onShowDetail,
onCopyPubKey,
isPublic,
i18n,
} = this.props;
@ -866,14 +881,19 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('retrySend')}
</MenuItem>
) : null}
<MenuItem
attributes={{
className: 'module-message__context__delete-message',
}}
onClick={onDelete}
>
{i18n('deleteMessage')}
</MenuItem>
{isDeletable ? (
<MenuItem
attributes={{
className: 'module-message__context__delete-message',
}}
onClick={onDelete}
>
{i18n('deleteMessage')}
</MenuItem>
) : null}
{isPublic ? (
<MenuItem onClick={onCopyPubKey}>{i18n('copyPublicKey')}</MenuItem>
) : null}
</ContextMenu>
);
}

@ -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<Props> {
textPending,
disableJumbomoji,
disableLinks,
isRss,
i18n,
} = this.props;
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
@ -93,6 +95,7 @@ export class MessageBody extends React.Component<Props> {
return this.addDownloading(
<Linkify
text={textWithPending}
isRss={isRss}
renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({
i18n,

@ -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;

@ -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"]
}

Loading…
Cancel
Save