Merge branch 'clearnet' into multi-device

* clearnet: (136 commits)
  Fix more check on deletion.
  Only shorten pubkeys if name is present
  Shorten pubkeys in quotations too
  better guard
  pass serverId back to the server
  Bump version.
  Purge cache on failure.
  Fix unnecessary link preview fetches.
  Review changes. Increase mod time to 30 seconds.
  Undo defaultPublicChatServer change,
  Modified colour of dark mod badge
  Linting.
  Updated design
  Show crown icon for moderators
  Fix last hash all being NULL in database
  Poll for moderators, store them on the conversation and use the list to determine our own mod status
  Make sure we are always updating the last deleted id
  Update ts/components/conversation/FriendRequest.md
  Fix #355 Display timestamp for friend requests
  Fix duplicate detection for sent messages in public chat
  ...

# Conflicts:
#	app/sql.js
#	js/background.js
#	js/models/messages.js
#	js/views/app_view.js
#	libloki/crypto.js
#	libtextsecure/message_receiver.js
This commit is contained in:
sachaaaaa 2019-09-16 13:55:32 +10:00
commit 0426d85e7a
71 changed files with 2761 additions and 321 deletions

View File

@ -22,7 +22,8 @@ module.exports = {
], ],
// Enforce curlies always // Enforce curlies always
curly: 'error', curly: ['error', 'all'],
'brace-style': ['error', '1tbs'],
// prevents us from accidentally checking in exclusive tests (`.only`): // prevents us from accidentally checking in exclusive tests (`.only`):
'mocha/no-exclusive-tests': 'error', 'mocha/no-exclusive-tests': 'error',

58
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,58 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Mocha Tests",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
"args": [
"--recursive",
"--exit",
"test/app",
"test/modules",
"ts/test",
"libloki/test/node"
],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "node",
"request": "launch",
"name": "Launch node Program",
"program": "${file}"
},
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${file}"
},
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}"
},
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"env": {
"NODE_APP_INSTANCE": "1"
},
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
},
"args": ["."],
"sourceMaps": true,
"outputCapture": "std"
}
]
}

View File

@ -948,6 +948,10 @@
"delete": { "delete": {
"message": "Delete" "message": "Delete"
}, },
"deletePublicWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel."
},
"deleteWarning": { "deleteWarning": {
"message": "message":
"Are you sure? Clicking 'delete' will permanently remove this message from this device only." "Are you sure? Clicking 'delete' will permanently remove this message from this device only."
@ -1037,11 +1041,27 @@
"message": "Delete messages", "message": "Delete messages",
"description": "Menu item for deleting messages, title case." "description": "Menu item for deleting messages, title case."
}, },
"deletePublicConversationConfirmation": {
"message":
"Permanently delete the messages locally from this public channel?",
"description":
"Confirmation dialog text that asks the user if they really wish to delete the public channel messages locally. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"deleteConversationConfirmation": { "deleteConversationConfirmation": {
"message": "Permanently delete this conversation?", "message": "Permanently delete this conversation?",
"description": "description":
"Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone." "Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
}, },
"deletePublicChannel": {
"message": "Leave public channel",
"description":
"Confirmation dialog title that asks the user if they really wish to delete a public channel. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"deletePublicChannelConfirmation": {
"message": "Leave this public channel?",
"description":
"Confirmation dialog text that tells the user what will happen if they leave the public channel."
},
"deleteContact": { "deleteContact": {
"message": "Delete contact", "message": "Delete contact",
"description": "description":
@ -1916,6 +1936,10 @@
"description": "description":
"Button action that the user can click to view their unique seed" "Button action that the user can click to view their unique seed"
}, },
"showQRCode": {
"message": "Show QR code",
"description": "Button action that the user can click to view their QR code"
},
"seedViewTitle": { "seedViewTitle": {
"message": "message":

View File

@ -1,8 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const mkdirp = require('mkdirp'); const mkdirp = require('mkdirp');
const path = require('path'); const path = require('path');
const Identicon = require('identicon.js');
const sha224 = require('js-sha512').sha512_224;
const { app } = require('electron').remote; const { app } = require('electron').remote;
@ -13,12 +11,6 @@ mkdirp.sync(PATH);
const hasImage = pubKey => fs.existsSync(getImagePath(pubKey)); const hasImage = pubKey => fs.existsSync(getImagePath(pubKey));
const getImagePath = pubKey => `${PATH}/${pubKey}.png`; const getImagePath = pubKey => `${PATH}/${pubKey}.png`;
const getOrCreateImagePath = pubKey => {
// If the image doesn't exist then create it
if (!hasImage(pubKey)) return generateImage(pubKey);
return getImagePath(pubKey);
};
const removeImage = pubKey => { const removeImage = pubKey => {
if (hasImage(pubKey)) { if (hasImage(pubKey)) {
@ -39,25 +31,14 @@ const removeImagesNotInArray = pubKeyArray => {
.forEach(i => removeImage(i)); .forEach(i => removeImage(i));
}; };
const generateImage = pubKey => { const writePNGImage = (base64String, pubKey) => {
const imagePath = getImagePath(pubKey); const imagePath = getImagePath(pubKey);
fs.writeFileSync(imagePath, base64String, 'base64');
/*
We hash the pubKey and then pass that into Identicon.
This is to avoid getting the same image
if 2 public keys start with the same 15 characters.
*/
const png = new Identicon(sha224(pubKey), {
margin: 0.2,
background: [0, 0, 0, 0],
}).toString();
fs.writeFileSync(imagePath, png, 'base64');
return imagePath; return imagePath;
}; };
module.exports = { module.exports = {
generateImage, writePNGImage,
getOrCreateImagePath,
getImagePath, getImagePath,
hasImage, hasImage,
removeImage, removeImage,

View File

@ -5,6 +5,7 @@ const sql = require('@journeyapps/sqlcipher');
const { app, dialog, clipboard } = require('electron'); const { app, dialog, clipboard } = require('electron');
const { redactAll } = require('../js/modules/privacy'); const { redactAll } = require('../js/modules/privacy');
const { remove: removeUserConfig } = require('./user_config'); const { remove: removeUserConfig } = require('./user_config');
const config = require('./config');
const pify = require('pify'); const pify = require('pify');
const uuidv4 = require('uuid/v4'); const uuidv4 = require('uuid/v4');
@ -100,10 +101,15 @@ module.exports = {
saveConversation, saveConversation,
saveConversations, saveConversations,
getConversationById, getConversationById,
savePublicServerToken,
getPublicServerTokenByServerUrl,
updateConversation, updateConversation,
removeConversation, removeConversation,
getAllConversations, getAllConversations,
getConversationsWithFriendStatus, getConversationsWithFriendStatus,
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
getAllConversationIds, getAllConversationIds,
getAllPrivateConversations, getAllPrivateConversations,
getAllGroupsInvolvingId, getAllGroupsInvolvingId,
@ -124,6 +130,7 @@ module.exports = {
removeMessage, removeMessage,
getUnreadByConversation, getUnreadByConversation,
getMessageBySender, getMessageBySender,
getMessageByServerId,
getMessageById, getMessageById,
getAllMessages, getAllMessages,
getAllMessageIds, getAllMessageIds,
@ -780,7 +787,140 @@ async function updateSchema(instance) {
await updateLokiSchema(instance); await updateLokiSchema(instance);
} }
const LOKI_SCHEMA_VERSIONS = [updateToLokiSchemaVersion2]; const LOKI_SCHEMA_VERSIONS = [
updateToLokiSchemaVersion1,
updateToLokiSchemaVersion2,
];
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 = {
// make sure we don't have a trailing slash just in case
serverUrl: config.get('defaultPublicChatServer').replace(/\/*$/, ''),
token: null,
};
console.log('lokiPublicServerData', lokiPublicServerData);
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@${lokiPublicServerData.serverUrl.replace(
/^https?:\/\//i,
''
)}`,
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 updateToLokiSchemaVersion2(currentVersion, instance) { async function updateToLokiSchemaVersion2(currentVersion, instance) {
if (currentVersion >= 2) { if (currentVersion >= 2) {
@ -799,10 +939,6 @@ async function updateToLokiSchemaVersion2(currentVersion, instance) {
);` );`
); );
await instance.run(`CREATE UNIQUE INDEX pairing_authorisations_secondary_device_pubkey ON pairingAuthorisations (
secondaryDevicePubKey
);`);
await instance.run( await instance.run(
`INSERT INTO loki_schema ( `INSERT INTO loki_schema (
version version
@ -816,7 +952,7 @@ async function updateToLokiSchemaVersion2(currentVersion, instance) {
async function updateLokiSchema(instance) { async function updateLokiSchema(instance) {
const result = await instance.get( const result = await instance.get(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema'" "SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';"
); );
if (!result) { if (!result) {
await createLokiSchemaTable(instance); await createLokiSchemaTable(instance);
@ -842,9 +978,9 @@ async function updateLokiSchema(instance) {
async function getLokiSchemaVersion(instance) { async function getLokiSchemaVersion(instance) {
const result = await instance.get( const result = await instance.get(
'SELECT version FROM loki_schema WHERE version = (SELECT MAX(version) FROM loki_schema);' 'SELECT MAX(version) as version FROM loki_schema;'
); );
if (!result.version) { if (!result || !result.version) {
return 0; return 0;
} }
return result.version; return result.version;
@ -1624,6 +1760,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) { async function getConversationById(id) {
const row = await db.get( const row = await db.get(
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`, `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`,
@ -1676,6 +1844,41 @@ async function getAllPrivateConversations() {
return map(rows, row => jsonToObject(row.json)); 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) { async function getAllGroupsInvolvingId(id) {
const rows = await db.all( const rows = await db.all(
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
@ -1783,6 +1986,7 @@ async function saveMessage(data, { forceSave } = {}) {
hasFileAttachments, hasFileAttachments,
hasVisualMediaAttachments, hasVisualMediaAttachments,
id, id,
serverId,
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
received_at, received_at,
schemaVersion, schemaVersion,
@ -1801,6 +2005,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id: id, $id: id,
$json: objectToJSON(data), $json: objectToJSON(data),
$serverId: serverId,
$body: body, $body: body,
$conversationId: conversationId, $conversationId: conversationId,
$expirationStartTimestamp: expirationStartTimestamp, $expirationStartTimestamp: expirationStartTimestamp,
@ -1823,6 +2028,7 @@ async function saveMessage(data, { forceSave } = {}) {
await db.run( await db.run(
`UPDATE messages SET `UPDATE messages SET
json = $json, json = $json,
serverId = $serverId,
body = $body, body = $body,
conversationId = $conversationId, conversationId = $conversationId,
expirationStartTimestamp = $expirationStartTimestamp, expirationStartTimestamp = $expirationStartTimestamp,
@ -1857,6 +2063,7 @@ async function saveMessage(data, { forceSave } = {}) {
id, id,
json, json,
serverId,
body, body,
conversationId, conversationId,
expirationStartTimestamp, expirationStartTimestamp,
@ -1877,6 +2084,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id, $id,
$json, $json,
$serverId,
$body, $body,
$conversationId, $conversationId,
$expirationStartTimestamp, $expirationStartTimestamp,
@ -1999,6 +2207,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) { async function getMessageById(id) {
const row = await db.get('SELECT * FROM messages WHERE id = $id;', { const row = await db.get('SELECT * FROM messages WHERE id = $id;', {
$id: id, $id: id,

View File

@ -315,6 +315,13 @@
</div> </div>
</div> </div>
</script> </script>
<script type='text/x-tmpl-mustache' id='qr-code-template'>
<div class="content">
<div id="qr">
</div>
<button class='ok' tabindex='1'>{{ ok }}</button>
</div>
</script>
<script type='text/x-tmpl-mustache' id='identicon-svg'> <script type='text/x-tmpl-mustache' id='identicon-svg'>
<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'> <svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>
<circle cx='50' cy='50' r='40' fill='{{ color }}' /> <circle cx='50' cy='50' r='40' fill='{{ color }}' />
@ -736,6 +743,7 @@
<script type='text/javascript' src='js/views/nickname_dialog_view.js'></script> <script type='text/javascript' src='js/views/nickname_dialog_view.js'></script>
<script type='text/javascript' src='js/views/password_dialog_view.js'></script> <script type='text/javascript' src='js/views/password_dialog_view.js'></script>
<script type='text/javascript' src='js/views/seed_dialog_view.js'></script> <script type='text/javascript' src='js/views/seed_dialog_view.js'></script>
<script type='text/javascript' src='js/views/qr_dialog_view.js'></script>
<script type='text/javascript' src='js/views/beta_release_disclaimer_view.js'></script> <script type='text/javascript' src='js/views/beta_release_disclaimer_view.js'></script>
<script type='text/javascript' src='js/views/identicon_svg_view.js'></script> <script type='text/javascript' src='js/views/identicon_svg_view.js'></script>
<script type='text/javascript' src='js/views/install_view.js'></script> <script type='text/javascript' src='js/views/install_view.js'></script>

View File

@ -4,14 +4,22 @@
"cdnUrl": "random.snode", "cdnUrl": "random.snode",
"contentProxyUrl": "", "contentProxyUrl": "",
"localServerPort": "8081", "localServerPort": "8081",
"defaultPoWDifficulty": "100", "defaultPoWDifficulty": "1",
"seedNodeList": [ "seedNodeList": [
{ {
"ip": "storage.testnetseed1.loki.network", "ip": "storage.seed1.loki.network",
"port": "22023"
},
{
"ip": "storage.seed2.loki.network",
"port": "38157"
},
{
"ip": "imaginary.stream",
"port": "38157" "port": "38157"
} }
], ],
"disableAutoUpdate": false, "disableAutoUpdate": true,
"updatesUrl": "https://updates2.signal.org/desktop", "updatesUrl": "https://updates2.signal.org/desktop",
"updatesPublicKey": "updatesPublicKey":
"fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", "fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401",
@ -22,5 +30,6 @@
"certificateAuthority": "certificateAuthority":
"-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n", "-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n",
"import": false, "import": false,
"serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx" "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx",
"defaultPublicChatServer": "https://chat.lokinet.org/"
} }

View File

@ -1,6 +1,12 @@
{ {
"storageProfile": "development1", "storageProfile": "development1",
"localServerPort": "8082", "localServerPort": "8082",
"disableAutoUpdate": true, "seedNodeList": [
"openDevTools": true {
"ip": "storage.testnetseed1.loki.network",
"port": "38157"
}
],
"openDevTools": true,
"defaultPublicChatServer": "https://chat-dev.lokinet.org/"
} }

View File

@ -1,4 +1,11 @@
{ {
"storageProfile": "development", "storageProfile": "development",
"openDevTools": true "seedNodeList": [
{
"ip": "storage.testnetseed1.loki.network",
"port": "38157"
}
],
"openDevTools": true,
"defaultPublicChatServer": "https://chat-dev.lokinet.org/"
} }

View File

@ -0,0 +1,4 @@
{
"storageProfile": "devprodProfile",
"openDevTools": true
}

View File

@ -0,0 +1,5 @@
{
"storageProfile": "devprod1Profile",
"localServerPort": "8082",
"openDevTools": true
}

View File

@ -1,16 +1 @@
{ {}
"seedNodeList": [
{
"ip": "storage.seed1.loki.network",
"port": "22023"
},
{
"ip": "storage.seed2.loki.network",
"port": "38157"
},
{
"ip": "imaginary.stream",
"port": "38157"
}
]
}

1
images/crown.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512pt" viewBox="0 -92 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="m419.25 277.976562h-326.5c-4.988281 0-9.648438-2.464843-12.457031-6.585937-43.078125-63.171875-46.382813-184.691406-46.632813-202.730469-.015625-.707031-.023437-1.421875-.023437-2.132812 0-8.316406 6.734375-15.0625 15.050781-15.078125h.03125c8.300781 0 15.046875 6.714843 15.078125 15.019531 0 .101562.003906.992188.039063 2.570312 1.328124 42.902344 36.644531 77.390626 79.863281 77.390626 44.058593 0 79.902343-35.84375 79.902343-79.902344 0-8.328125 6.753907-15.078125 15.078126-15.078125h34.636718c8.328125 0 15.078125 6.75 15.078125 15.078125 0 44.058594 35.847657 79.902344 79.90625 79.902344 43.257813 0 78.597657-34.550782 79.867188-77.507813.027343-1.507813.035156-2.351563.035156-2.449219.03125-8.308594 6.773437-15.023437 15.078125-15.023437h.027344c8.316406.015625 15.050781 6.761719 15.050781 15.078125 0 .714844-.007813 1.425781-.019531 2.136718-.253906 18.035157-3.558594 139.558594-46.632813 202.730469-2.808593 4.117188-7.472656 6.582031-12.457031 6.582031zm0 0" fill="#fff780"/><path d="m463.308594 51.449219c-.007813 0-.015625 0-.027344 0-8.300781 0-15.046875 6.714843-15.078125 15.019531 0 .101562-.003906.945312-.035156 2.453125-1.269531 42.953125-36.609375 77.507813-79.867188 77.507813-44.058593 0-79.902343-35.84375-79.902343-79.902344 0-8.328125-6.753907-15.078125-15.078126-15.078125h-17.316406v226.523437h163.246094c4.988281 0 9.648438-2.464844 12.457031-6.582031 43.078125-63.171875 46.382813-184.695313 46.632813-202.730469.015625-.707031.023437-1.421875.023437-2.132812-.003906-8.316406-6.738281-15.0625-15.054687-15.078125zm0 0" fill="#ffc02e"/><path d="m256 0c-26.863281 0-48.71875 21.855469-48.71875 48.71875s21.855469 48.714844 48.71875 48.714844 48.71875-21.851563 48.71875-48.714844-21.855469-48.71875-48.71875-48.71875zm0 0" fill="#ffc02e"/><path d="m256.003906 0v97.4375c26.863282-.003906 48.714844-21.855469 48.714844-48.71875s-21.855469-48.71484375-48.714844-48.71875zm0 0" fill="#ffa73b"/><path d="m48.71875 37.597656c-26.863281 0-48.71875 21.855469-48.71875 48.71875 0 26.863282 21.855469 48.71875 48.71875 48.71875s48.714844-21.855468 48.714844-48.71875c0-26.863281-21.851563-48.71875-48.714844-48.71875zm0 0" fill="#ffc02e"/><path d="m463.28125 37.597656c-26.863281 0-48.714844 21.855469-48.714844 48.71875 0 26.859375 21.851563 48.714844 48.714844 48.714844s48.71875-21.855469 48.71875-48.714844c0-26.863281-21.855469-48.71875-48.71875-48.71875zm0 0" fill="#ffa73b"/><path d="m419.25 327.441406h-326.5c-8.328125 0-15.078125-6.75-15.078125-15.078125v-44.964843h356.65625v44.964843c0 8.328125-6.75 15.078125-15.078125 15.078125zm0 0" fill="#ffc02e"/><path d="m256.003906 327.441406h163.246094c8.328125 0 15.078125-6.75 15.078125-15.078125v-44.964843h-178.324219zm0 0" fill="#ffa73b"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -55,6 +55,7 @@
'check.svg', 'check.svg',
'clock.svg', 'clock.svg',
'close-circle.svg', 'close-circle.svg',
'crown.svg',
'delete.svg', 'delete.svg',
'dots-horizontal.svg', 'dots-horizontal.svg',
'double-check.svg', 'double-check.svg',
@ -206,14 +207,41 @@
window.log.info('Storage fetch'); window.log.info('Storage fetch');
storage.fetch(); storage.fetch();
let specialConvInited = false;
const initSpecialConversations = async () => {
if (specialConvInited) {
return
}
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 => {
// weird but create the object and does everything we need
conversation.getPublicSendData();
});
specialConvInited = true;
};
let initialisedAPI = false; let initialisedAPI = false;
const initAPIs = () => { const initAPIs = async () => {
if (initialisedAPI) { if (initialisedAPI) {
return; return;
} }
const ourKey = textsecure.storage.user.getNumber(); const ourKey = textsecure.storage.user.getNumber();
window.feeds = [];
window.lokiMessageAPI = new window.LokiMessageAPI(ourKey); window.lokiMessageAPI = new window.LokiMessageAPI(ourKey);
window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey);
window.lokiP2pAPI = new window.LokiP2pAPI(ourKey); window.lokiP2pAPI = new window.LokiP2pAPI(ourKey);
window.lokiP2pAPI.on('pingContact', pubKey => { window.lokiP2pAPI.on('pingContact', pubKey => {
const isPing = true; const isPing = true;
@ -255,11 +283,6 @@
} }
first = false; first = false;
if (Whisper.Registration.isDone()) {
startLocalLokiServer();
initAPIs();
}
const currentPoWDifficulty = storage.get('PoWDifficulty', null); const currentPoWDifficulty = storage.get('PoWDifficulty', null);
if (!currentPoWDifficulty) { if (!currentPoWDifficulty) {
storage.put('PoWDifficulty', window.getDefaultPoWDifficulty()); storage.put('PoWDifficulty', window.getDefaultPoWDifficulty());
@ -475,6 +498,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', () => { Whisper.events.on('setupAsNewDevice', () => {
const { appView } = window.owsDesktopApp; const { appView } = window.owsDesktopApp;
if (appView) { if (appView) {
@ -567,11 +612,9 @@
window.log.info('Cleanup: complete'); window.log.info('Cleanup: complete');
window.log.info('listening for registration events'); window.log.info('listening for registration events');
Whisper.events.on('registration_done', () => { Whisper.events.on('registration_done', async () => {
window.log.info('handling registration event'); window.log.info('handling registration event');
startLocalLokiServer();
// listeners // listeners
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
// window.Signal.RefreshSenderCertificate.initialize({ // window.Signal.RefreshSenderCertificate.initialize({
@ -581,7 +624,6 @@
// logger: window.log, // logger: window.log,
// }); // });
initAPIs();
connect(true); connect(true);
}); });
@ -721,6 +763,13 @@
} }
}); });
Whisper.events.on('showQRDialog', async () => {
if (appView) {
const ourNumber = textsecure.storage.user.getNumber();
appView.showQRDialog(ourNumber);
}
});
Whisper.events.on('showDevicePairingDialog', async () => { Whisper.events.on('showDevicePairingDialog', async () => {
if (appView) { if (appView) {
appView.showDevicePairingDialog(); appView.showDevicePairingDialog();
@ -745,6 +794,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', () => { Whisper.events.on('password-updated', () => {
if (appView && appView.inboxView) { if (appView && appView.inboxView) {
appView.inboxView.trigger('password-updated'); appView.inboxView.trigger('password-updated');
@ -861,6 +922,9 @@
Whisper.Notifications.disable(); // avoid notification flood until empty Whisper.Notifications.disable(); // avoid notification flood until empty
// initialize the socket and start listening for messages // initialize the socket and start listening for messages
startLocalLokiServer();
await initAPIs();
await initSpecialConversations();
messageReceiver = new textsecure.MessageReceiver( messageReceiver = new textsecure.MessageReceiver(
USERNAME, USERNAME,
PASSWORD, PASSWORD,
@ -1299,7 +1363,21 @@
return handleProfileUpdate({ data, confirm, messageDescriptor }); return handleProfileUpdate({ data, confirm, messageDescriptor });
} }
const message = await createMessage(data); const ourNumber = textsecure.storage.user.getNumber();
const descriptorId = await textsecure.MessageReceiver.arrayBufferToString(
messageDescriptor.id
);
let message;
if (
messageDescriptor.type === 'group' &&
descriptorId.match(/^publicChat:/) &&
data.source === ourNumber
) {
// Public chat messages from ourselves should be outgoing
message = await createSentMessage(data);
} else {
message = await createMessage(data);
}
const isDuplicate = await isMessageDuplicate(message); const isDuplicate = await isMessageDuplicate(message);
if (isDuplicate) { if (isDuplicate) {
window.log.warn('Received duplicate message', message.idForLogging()); window.log.warn('Received duplicate message', message.idForLogging());
@ -1384,10 +1462,10 @@
return new Whisper.Message({ return new Whisper.Message({
source: textsecure.storage.user.getNumber(), source: textsecure.storage.user.getNumber(),
sourceDevice: data.device, sourceDevice: data.sourceDevice,
sent_at: data.timestamp, sent_at: data.timestamp,
sent_to: sentTo, sent_to: sentTo,
received_at: now, received_at: data.isPublic ? data.receivedAt : now,
conversationId: data.destination, conversationId: data.destination,
type: 'outgoing', type: 'outgoing',
sent: true, sent: true,
@ -1425,6 +1503,7 @@
let messageData = { let messageData = {
source: data.source, source: data.source,
sourceDevice: data.sourceDevice, sourceDevice: data.sourceDevice,
serverId: data.serverId,
sent_at: data.timestamp, sent_at: data.timestamp,
received_at: data.receivedAt || Date.now(), received_at: data.receivedAt || Date.now(),
conversationId: data.source, conversationId: data.source,
@ -1432,6 +1511,8 @@
type: 'incoming', type: 'incoming',
unread: 1, unread: 1,
isP2p: data.isP2p, isP2p: data.isP2p,
isPublic: data.isPublic,
isRss: data.isRss,
}; };
if (data.friendRequest) { if (data.friendRequest) {

View File

@ -42,7 +42,9 @@
storage.addBlockedNumber(number); storage.addBlockedNumber(number);
// Make sure we don't add duplicates // Make sure we don't add duplicates
if (blockedNumbers.getModel(number)) return; if (blockedNumbers.getModel(number)) {
return;
}
blockedNumbers.add({ number }); blockedNumbers.add({ number });
}, },

View File

@ -1,4 +1,4 @@
/* global _, Whisper, Backbone, storage, textsecure, libsignal */ /* global _, Whisper, Backbone, storage, textsecure, libsignal, lokiPublicChatAPI */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -159,6 +159,10 @@
if (!conversation) { if (!conversation) {
return; return;
} }
if (conversation.isPublic()) {
const server = conversation.getPublicSource();
lokiPublicChatAPI.unregisterChannel(server.server, server.channelId);
}
await conversation.destroyMessages(); await conversation.destroyMessages();
const deviceIds = await textsecure.storage.protocol.getDeviceIds(id); const deviceIds = await textsecure.storage.protocol.getDeviceIds(id);
await Promise.all( await Promise.all(

View File

@ -13,6 +13,9 @@
window.Signal = window.Signal || {}; window.Signal = window.Signal || {};
window.Signal.LinkPreviews = window.Signal.LinkPreviews || {}; window.Signal.LinkPreviews = window.Signal.LinkPreviews || {};
// A cache mapping url to fetched previews
const previewCache = {};
async function makeChunkedRequest(url) { async function makeChunkedRequest(url) {
const PARALLELISM = 3; const PARALLELISM = 3;
const size = await textsecure.messaging.getProxiedSize(url); const size = await textsecure.messaging.getProxiedSize(url);
@ -68,13 +71,36 @@
return StringView.arrayBufferToHex(digest); return StringView.arrayBufferToHex(digest);
} }
async function getPreview(url) { // Wrapper function which utilizes cache
async function getPreview(url, skipCache = false) {
// If we have a request cached then use that
if (!skipCache && url in previewCache) {
return previewCache[url];
}
// Start the request
const promise = _getPreview(url).catch(e => {
window.log.error(e);
// If we get an error then we can purge the cache
if (url in previewCache) {
delete previewCache[url];
}
return null;
});
previewCache[url] = promise;
return promise;
}
async function _getPreview(url) {
let html; let html;
try { try {
html = await textsecure.messaging.makeProxiedRequest(url); html = await textsecure.messaging.makeProxiedRequest(url);
} catch (error) { } catch (error) {
if (error.code >= 300) { if (error.code >= 300) {
return null; throw new Error(`Failed to fetch html: ${error}`);
} }
} }

View File

@ -11,6 +11,7 @@
clipboard, clipboard,
BlockedNumberController, BlockedNumberController,
lokiP2pAPI, lokiP2pAPI,
lokiPublicChatAPI,
JobQueue JobQueue
*/ */
@ -193,6 +194,15 @@
isMe() { isMe() {
return this.id === this.ourNumber; return this.id === this.ourNumber;
}, },
isPublic() {
return this.id && this.id.match(/^publicChat:/);
},
isClosable() {
return !this.isRss() || this.get('closable');
},
isRss() {
return this.id && this.id.match(/^rss:/);
},
isBlocked() { isBlocked() {
return BlockedNumberController.isBlocked(this.id); return BlockedNumberController.isBlocked(this.id);
}, },
@ -299,8 +309,15 @@
}, },
async updateProfileAvatar() { async updateProfileAvatar() {
const path = profileImages.getOrCreateImagePath(this.id); if (this.isRss() || this.isPublic()) {
await this.setProfileAvatar(path); return;
}
// Remove old identicons
if (profileImages.hasImage(this.id)) {
profileImages.removeImage(this.id);
await this.setProfileAvatar(null);
}
}, },
async updateAndMerge(message) { async updateAndMerge(message) {
@ -347,7 +364,9 @@
// Get messages with the given timestamp // Get messages with the given timestamp
_getMessagesWithTimestamp(pubKey, timestamp) { _getMessagesWithTimestamp(pubKey, timestamp) {
if (this.id !== pubKey) return []; if (this.id !== pubKey) {
return [];
}
// Go through our messages and find the one that we need to update // Go through our messages and find the one that we need to update
return this.messageCollection.models.filter( return this.messageCollection.models.filter(
@ -365,6 +384,16 @@
await Promise.all(messages.map(m => m.setIsP2p(true))); 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) { async onNewMessage(message) {
await this.updateLastMessage(); await this.updateLastMessage();
@ -393,7 +422,9 @@
// Get the pending friend requests that match the direction // Get the pending friend requests that match the direction
// If no direction is supplied then return all pending friend requests // If no direction is supplied then return all pending friend requests
return messages.models.filter(m => { return messages.models.filter(m => {
if (!status.includes(m.get('friendStatus'))) return false; if (!status.includes(m.get('friendStatus'))) {
return false;
}
return direction === null || m.get('direction') === direction; return direction === null || m.get('direction') === direction;
}); });
}, },
@ -403,7 +434,9 @@
addSingleMessage(message, setToExpire = true) { addSingleMessage(message, setToExpire = true) {
const model = this.messageCollection.add(message, { merge: true }); const model = this.messageCollection.add(message, { merge: true });
if (setToExpire) model.setToExpire(); if (setToExpire) {
model.setToExpire();
}
return model; return model;
}, },
format() { format() {
@ -424,6 +457,8 @@
color, color,
type: this.isPrivate() ? 'direct' : 'group', type: this.isPrivate() ? 'direct' : 'group',
isMe: this.isMe(), isMe: this.isMe(),
isPublic: this.isPublic(),
isClosable: this.isClosable(),
isTyping: typingKeys.length > 0, isTyping: typingKeys.length > 0,
lastUpdated: this.get('timestamp'), lastUpdated: this.get('timestamp'),
name: this.getName(), name: this.getName(),
@ -440,6 +475,7 @@
lastMessage: { lastMessage: {
status: this.get('lastMessageStatus'), status: this.get('lastMessageStatus'),
text: this.get('lastMessage'), text: this.get('lastMessage'),
isRss: this.isRss(),
}, },
isOnline: this.isOnline(), isOnline: this.isOnline(),
hasNickname: !!this.getNickname(), hasNickname: !!this.getNickname(),
@ -629,6 +665,11 @@
); );
}, },
updateTextInputState() { updateTextInputState() {
if (this.isRss()) {
// or if we're an rss conversation, disable it
this.trigger('disable:input', true);
return;
}
switch (this.get('friendRequestStatus')) { switch (this.get('friendRequestStatus')) {
case FriendRequestStatusEnum.none: case FriendRequestStatusEnum.none:
case FriendRequestStatusEnum.requestExpired: case FriendRequestStatusEnum.requestExpired:
@ -651,7 +692,9 @@
}, },
async setFriendRequestStatus(newStatus) { async setFriendRequestStatus(newStatus) {
// Ensure that the new status is a valid FriendStatusEnum value // Ensure that the new status is a valid FriendStatusEnum value
if (!(newStatus in Object.values(FriendRequestStatusEnum))) return; if (!(newStatus in Object.values(FriendRequestStatusEnum))) {
return;
}
if ( if (
this.ourNumber === this.id && this.ourNumber === this.id &&
newStatus !== FriendRequestStatusEnum.friends newStatus !== FriendRequestStatusEnum.friends
@ -669,11 +712,15 @@
async respondToAllFriendRequests(options) { async respondToAllFriendRequests(options) {
const { response, status, direction = null } = options; const { response, status, direction = null } = options;
// Ignore if no response supplied // Ignore if no response supplied
if (!response) return; if (!response) {
return;
}
const pending = await this.getFriendRequests(direction, status); const pending = await this.getFriendRequests(direction, status);
await Promise.all( await Promise.all(
pending.map(async request => { pending.map(async request => {
if (request.hasErrors()) return; if (request.hasErrors()) {
return;
}
request.set({ friendStatus: response }); request.set({ friendStatus: response });
await window.Signal.Data.saveMessage(request.attributes, { await window.Signal.Data.saveMessage(request.attributes, {
@ -707,7 +754,9 @@
}, },
// We have accepted an incoming friend request // We have accepted an incoming friend request
async onAcceptFriendRequest() { async onAcceptFriendRequest() {
if (this.unlockTimer) clearTimeout(this.unlockTimer); if (this.unlockTimer) {
clearTimeout(this.unlockTimer);
}
if (this.hasReceivedFriendRequest()) { if (this.hasReceivedFriendRequest()) {
this.setFriendRequestStatus(FriendRequestStatusEnum.friends); this.setFriendRequestStatus(FriendRequestStatusEnum.friends);
await this.respondToAllFriendRequests({ await this.respondToAllFriendRequests({
@ -723,7 +772,9 @@
if (this.isFriend()) { if (this.isFriend()) {
return false; return false;
} }
if (this.unlockTimer) clearTimeout(this.unlockTimer); if (this.unlockTimer) {
clearTimeout(this.unlockTimer);
}
if (this.hasSentFriendRequest()) { if (this.hasSentFriendRequest()) {
this.setFriendRequestStatus(FriendRequestStatusEnum.friends); this.setFriendRequestStatus(FriendRequestStatusEnum.friends);
await this.respondToAllFriendRequests({ await this.respondToAllFriendRequests({
@ -737,9 +788,13 @@
}, },
async onFriendRequestTimeout() { async onFriendRequestTimeout() {
// Unset the timer // Unset the timer
if (this.unlockTimer) clearTimeout(this.unlockTimer); if (this.unlockTimer) {
clearTimeout(this.unlockTimer);
}
this.unlockTimer = null; this.unlockTimer = null;
if (this.isFriend()) return; if (this.isFriend()) {
return;
}
// Set the unlock timestamp to null // Set the unlock timestamp to null
if (this.get('unlockTimestamp')) { if (this.get('unlockTimestamp')) {
@ -793,7 +848,9 @@
await this.setFriendRequestStatus(FriendRequestStatusEnum.requestSent); await this.setFriendRequestStatus(FriendRequestStatusEnum.requestSent);
}, },
setFriendRequestExpiryTimeout() { setFriendRequestExpiryTimeout() {
if (this.isFriend()) return; if (this.isFriend()) {
return;
}
const unlockTimestamp = this.get('unlockTimestamp'); const unlockTimestamp = this.get('unlockTimestamp');
if (unlockTimestamp && !this.unlockTimer) { if (unlockTimestamp && !this.unlockTimer) {
const delta = Math.max(unlockTimestamp - Date.now(), 0); const delta = Math.max(unlockTimestamp - Date.now(), 0);
@ -1051,12 +1108,18 @@
}, },
validateNumber() { validateNumber() {
if (!this.id) return 'Invalid ID'; if (!this.id) {
if (!this.isPrivate()) return null; return 'Invalid ID';
}
if (!this.isPrivate()) {
return null;
}
// Check if it's hex // Check if it's hex
const isHex = this.id.replace(/[\s]*/g, '').match(/^[0-9a-fA-F]+$/); const isHex = this.id.replace(/[\s]*/g, '').match(/^[0-9a-fA-F]+$/);
if (!isHex) return 'Invalid Hex ID'; if (!isHex) {
return 'Invalid Hex ID';
}
// Check if the pubkey length is 33 and leading with 05 or of length 32 // Check if the pubkey length is 33 and leading with 05 or of length 32
const len = this.id.length; const len = this.id.length;
@ -1187,7 +1250,9 @@
async sendMessage(body, attachments, quote, preview) { async sendMessage(body, attachments, quote, preview) {
// Input should be blocked if there is a pending friend request // Input should be blocked if there is a pending friend request
if (this.isPendingFriendRequest()) return; if (this.isPendingFriendRequest()) {
return;
}
this.clearTypingTimers(); this.clearTypingTimers();
@ -1248,7 +1313,9 @@
// If the requests didn't error then don't add a new friend request // If the requests didn't error then don't add a new friend request
// because one of them was sent successfully // because one of them was sent successfully
if (friendRequestSent) return null; if (friendRequestSent) {
return null;
}
} }
await this.setFriendRequestStatus( await this.setFriendRequestStatus(
FriendRequestStatusEnum.pendingSend FriendRequestStatusEnum.pendingSend
@ -1270,6 +1337,10 @@
if (this.isPrivate()) { if (this.isPrivate()) {
messageWithSchema.destination = destination; messageWithSchema.destination = destination;
} else if (this.isPublic()) {
// Public chats require this data to detect duplicates
messageWithSchema.source = textsecure.storage.user.getNumber();
messageWithSchema.sourceDevice = 1;
} }
const attributes = { const attributes = {
...messageWithSchema, ...messageWithSchema,
@ -1347,6 +1418,10 @@
const options = this.getSendOptions(); const options = this.getSendOptions();
options.messageType = message.get('type'); options.messageType = message.get('type');
options.isPublic = this.isPublic();
if (options.isPublic) {
options.publicSendData = this.getPublicSendData();
}
const groupNumbers = this.getRecipients(); const groupNumbers = this.getRecipients();
@ -1729,7 +1804,9 @@
}, },
async setSessionResetStatus(newStatus) { async setSessionResetStatus(newStatus) {
// Ensure that the new status is a valid SessionResetEnum value // Ensure that the new status is a valid SessionResetEnum value
if (!(newStatus in Object.values(SessionResetEnum))) return; if (!(newStatus in Object.values(SessionResetEnum))) {
return;
}
if (this.get('sessionResetStatus') !== newStatus) { if (this.get('sessionResetStatus') !== newStatus) {
this.set({ sessionResetStatus: newStatus }); this.set({ sessionResetStatus: newStatus });
await window.Signal.Data.updateConversation(this.id, this.attributes, { await window.Signal.Data.updateConversation(this.id, this.attributes, {
@ -1946,7 +2023,7 @@
return; return;
} }
if (read.length && options.sendReadReceipts) { if (!this.isPublic() && read.length && options.sendReadReceipts) {
window.log.info(`Sending ${read.length} read receipts`); window.log.info(`Sending ${read.length} read receipts`);
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes // Because syncReadMessages sends to our other devices, and sendReadReceipts goes
// to a contact, we need accessKeys for both. // to a contact, we need accessKeys for both.
@ -1981,7 +2058,9 @@
async setNickname(nickname) { async setNickname(nickname) {
const trimmed = nickname && nickname.trim(); const trimmed = nickname && nickname.trim();
if (this.get('nickname') === trimmed) return; if (this.get('nickname') === trimmed) {
return;
}
this.set({ nickname: trimmed }); this.set({ nickname: trimmed });
await window.Signal.Data.updateConversation(this.id, this.attributes, { await window.Signal.Data.updateConversation(this.id, this.attributes, {
@ -2015,6 +2094,75 @@
getNickname() { getNickname() {
return this.get('nickname'); 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'),
};
},
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(pubKey) {
if (!this.isPublic()) {
return false;
}
const moderators = this.get('moderators');
return Array.isArray(moderators) && moderators.includes(pubKey);
},
async setModerators(moderators) {
if (!this.isPublic()) {
return;
}
// TODO: compare array properly
if (!_.isEqual(this.get('moderators'), moderators)) {
this.set({ moderators });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
}
},
// SIGNAL PROFILES // SIGNAL PROFILES
@ -2052,6 +2200,32 @@
}); });
} }
}, },
async setGroupName(name) {
const profileName = this.get('name');
if (profileName !== name) {
this.set({ name });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
}
},
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) { async setProfileAvatar(avatarPath) {
const profileAvatar = this.get('profileAvatar'); const profileAvatar = this.get('profileAvatar');
if (profileAvatar !== avatarPath) { if (profileAvatar !== avatarPath) {
@ -2195,17 +2369,47 @@
}, },
deleteContact() { deleteContact() {
const message = this.isPublic()
? i18n('deletePublicChannelConfirmation')
: i18n('deleteContactConfirmation');
Whisper.events.trigger('showConfirmationDialog', { Whisper.events.trigger('showConfirmationDialog', {
message: i18n('deleteContactConfirmation'), message,
onOk: () => ConversationController.deleteContact(this.id), onOk: () => ConversationController.deleteContact(this.id),
}); });
}, },
async deletePublicMessage(message) {
const channelAPI = this.getPublicSendData();
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() { deleteMessages() {
Whisper.events.trigger('showConfirmationDialog', { if (this.isPublic()) {
message: i18n('deleteConversationConfirmation'), Whisper.events.trigger('showConfirmationDialog', {
onOk: () => this.destroyMessages(), message: i18n('deletePublicConversationConfirmation'),
}); onOk: () => ConversationController.deleteContact(this.id),
});
} else {
Whisper.events.trigger('showConfirmationDialog', {
message: i18n('deleteContactConfirmation'),
onOk: () => ConversationController.deleteContact(this.id),
});
}
}, },
async destroyMessages() { async destroyMessages() {
@ -2311,7 +2515,9 @@
const avatar = this.get('avatar') || this.get('profileAvatar'); const avatar = this.get('avatar') || this.get('profileAvatar');
if (avatar) { if (avatar) {
if (avatar.path) return getAbsoluteAttachmentPath(avatar.path); if (avatar.path) {
return getAbsoluteAttachmentPath(avatar.path);
}
return avatar; return avatar;
} }
@ -2355,7 +2561,9 @@
} }
return this.notifyFriendRequest(message.get('source'), 'requested'); return this.notifyFriendRequest(message.get('source'), 'requested');
} }
if (!message.isIncoming()) return Promise.resolve(); if (!message.isIncoming()) {
return Promise.resolve();
}
const conversationId = this.id; const conversationId = this.id;
return ConversationController.getOrCreateAndWait( return ConversationController.getOrCreateAndWait(
@ -2388,7 +2596,9 @@
// Notification for friend request received // Notification for friend request received
async notifyFriendRequest(source, type) { async notifyFriendRequest(source, type) {
// Data validation // Data validation
if (!source) throw new Error('Invalid source'); if (!source) {
throw new Error('Invalid source');
}
if (!['accepted', 'requested'].includes(type)) { if (!['accepted', 'requested'].includes(type)) {
throw new Error('Type must be accepted or requested.'); throw new Error('Type must be accepted or requested.');
} }

View File

@ -323,7 +323,9 @@
getNotificationText() { getNotificationText() {
const description = this.getDescription(); const description = this.getDescription();
if (description) { if (description) {
if (this.isFriendRequest()) return `Friend Request: ${description}`; if (this.isFriendRequest()) {
return `Friend Request: ${description}`;
}
return description; return description;
} }
if (this.get('attachments').length > 0) { if (this.get('attachments').length > 0) {
@ -433,7 +435,9 @@
}, },
async acceptFriendRequest() { async acceptFriendRequest() {
if (this.get('friendStatus') !== 'pending') return; if (this.get('friendStatus') !== 'pending') {
return;
}
const conversation = this.getConversation(); const conversation = this.getConversation();
this.set({ friendStatus: 'accepted' }); this.set({ friendStatus: 'accepted' });
@ -443,7 +447,9 @@
conversation.onAcceptFriendRequest(); conversation.onAcceptFriendRequest();
}, },
async declineFriendRequest() { async declineFriendRequest() {
if (this.get('friendStatus') !== 'pending') return; if (this.get('friendStatus') !== 'pending') {
return;
}
const conversation = this.getConversation(); const conversation = this.getConversation();
this.set({ friendStatus: 'declined' }); this.set({ friendStatus: 'declined' });
@ -478,6 +484,7 @@
return { return {
text: this.createNonBreakingLastSeparator(this.get('body')), text: this.createNonBreakingLastSeparator(this.get('body')),
timestamp: this.get('sent_at'),
status: this.getMessagePropStatus(), status: this.getMessagePropStatus(),
direction, direction,
friendStatus, friendStatus,
@ -592,7 +599,9 @@
return 'sent'; return 'sent';
} }
const calculatingPoW = this.get('calculatingPoW'); const calculatingPoW = this.get('calculatingPoW');
if (calculatingPoW) return 'pow'; if (calculatingPoW) {
return 'pow';
}
return 'sending'; return 'sending';
}, },
@ -671,8 +680,18 @@
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
isP2p: !!this.get('isP2p'), isP2p: !!this.get('isP2p'),
isPublic: !!this.get('isPublic'),
isRss: !!this.get('isRss'),
isModerator:
!!this.get('isPublic') &&
this.getConversation().isModerator(this.getSource()),
isDeletable:
!this.get('isPublic') ||
this.getConversation().isModerator(this.OUR_NUMBER) ||
this.getSource() === this.OUR_NUMBER,
onCopyText: () => this.copyText(), onCopyText: () => this.copyText(),
onCopyPubKey: () => this.copyPubKey(),
onReply: () => this.trigger('reply', this), onReply: () => this.trigger('reply', this),
onRetrySend: () => this.retrySend(), onRetrySend: () => this.retrySend(),
onShowDetail: () => this.trigger('show-message-detail', this), onShowDetail: () => this.trigger('show-message-detail', this),
@ -969,6 +988,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() { copyText() {
clipboard.writeText(this.get('body')); clipboard.writeText(this.get('body'));
window.Whisper.events.trigger('showToast', { window.Whisper.events.trigger('showToast', {
@ -1228,7 +1258,9 @@
return null; return null;
}, },
async setCalculatingPoW() { async setCalculatingPoW() {
if (this.calculatingPoW) return; if (this.calculatingPoW) {
return;
}
this.set({ this.set({
calculatingPoW: true, calculatingPoW: true,
@ -1239,7 +1271,9 @@
}); });
}, },
async setIsP2p(isP2p) { async setIsP2p(isP2p) {
if (_.isEqual(this.get('isP2p'), isP2p)) return; if (_.isEqual(this.get('isP2p'), isP2p)) {
return;
}
this.set({ this.set({
isP2p: !!isP2p, isP2p: !!isP2p,
@ -1249,6 +1283,35 @@
Message: Whisper.Message, 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) { send(promise) {
this.trigger('pending'); this.trigger('pending');
return promise return promise
@ -2072,7 +2135,9 @@
// Need to do this here because the conversation has already changed states // Need to do this here because the conversation has already changed states
if (autoAccept) { if (autoAccept) {
await conversation.notifyFriendRequest(source, 'accepted'); await conversation.notifyFriendRequest(source, 'accepted');
} else await conversation.notify(message); } else {
await conversation.notify(message);
}
} }
confirm(); confirm();

View File

@ -127,6 +127,11 @@ module.exports = {
getConversationsWithFriendStatus, getConversationsWithFriendStatus,
getAllConversationIds, getAllConversationIds,
getAllPrivateConversations, getAllPrivateConversations,
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
savePublicServerToken,
getPublicServerTokenByServerUrl,
getAllGroupsInvolvingId, getAllGroupsInvolvingId,
searchConversations, searchConversations,
@ -149,6 +154,7 @@ module.exports = {
removeAllMessagesInConversation, removeAllMessagesInConversation,
getMessageBySender, getMessageBySender,
getMessageByServerId,
getMessageById, getMessageById,
getAllMessages, getAllMessages,
getAllUnsentMessages, getAllUnsentMessages,
@ -728,7 +734,9 @@ async function getAllSessions(id) {
// Conversation // Conversation
function setifyProperty(data, propertyName) { function setifyProperty(data, propertyName) {
if (!data) return data; if (!data) {
return data;
}
const returnData = { ...data }; const returnData = { ...data };
if (Array.isArray(returnData[propertyName])) { if (Array.isArray(returnData[propertyName])) {
returnData[propertyName] = new Set(returnData[propertyName]); returnData[propertyName] = new Set(returnData[propertyName]);
@ -822,6 +830,22 @@ async function getAllConversationIds() {
return ids; 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 }) { async function getAllPrivateConversations({ ConversationCollection }) {
const conversations = await channels.getAllPrivateConversations(); const conversations = await channels.getAllPrivateConversations();
@ -830,6 +854,26 @@ async function getAllPrivateConversations({ ConversationCollection }) {
return collection; 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 }) { async function getAllGroupsInvolvingId(id, { ConversationCollection }) {
const conversations = await channels.getAllGroupsInvolvingId(id); const conversations = await channels.getAllGroupsInvolvingId(id);
@ -949,6 +993,15 @@ async function _removeMessages(ids) {
await channels.removeMessage(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 }) { async function getMessageById(id, { Message }) {
const message = await channels.getMessageById(id); const message = await channels.getMessageById(id);
if (!message) { if (!message) {

View File

@ -31,9 +31,14 @@ const SUPPORTED_DOMAINS = [
'imgur.com', 'imgur.com',
'www.imgur.com', 'www.imgur.com',
'm.imgur.com', 'm.imgur.com',
'i.imgur.com',
'instagram.com', 'instagram.com',
'www.instagram.com', 'www.instagram.com',
'm.instagram.com', 'm.instagram.com',
'tenor.com',
'gph.is',
'giphy.com',
'media.giphy.com',
]; ];
function isLinkInWhitelist(link) { function isLinkInWhitelist(link) {
try { try {
@ -58,7 +63,7 @@ function isLinkInWhitelist(link) {
} }
} }
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com|fbcdn.net)$/i; const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com|fbcdn.net|giphy.com|tenor.com)$/i;
function isMediaLinkInWhitelist(link) { function isMediaLinkInWhitelist(link) {
try { try {
const url = new URL(link); const url = new URL(link);
@ -81,8 +86,8 @@ function isMediaLinkInWhitelist(link) {
} }
} }
const META_TITLE = /<meta\s+property="og:title"\s+content="([\s\S]+?)"\s*\/?\s*>/im; const META_TITLE = /<meta\s+(?:class="dynamic"\s+)?property="og:title"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
const META_IMAGE = /<meta\s+property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im; const META_IMAGE = /<meta\s+(?:class="dynamic"\s+)?property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
function _getMetaTag(html, regularExpression) { function _getMetaTag(html, regularExpression) {
const match = regularExpression.exec(html); const match = regularExpression.exec(html);
if (match && match[1]) { if (match && match[1]) {
@ -96,7 +101,8 @@ function getTitleMetaTag(html) {
return _getMetaTag(html, META_TITLE); return _getMetaTag(html, META_TITLE);
} }
function getImageMetaTag(html) { function getImageMetaTag(html) {
return _getMetaTag(html, META_IMAGE); const tag = _getMetaTag(html, META_IMAGE);
return typeof tag === 'string' ? tag.replace('http://', 'https://') : tag;
} }
function findLinks(text, caretLocation) { function findLinks(text, caretLocation) {

View File

@ -78,13 +78,41 @@ class LokiMessageAPI {
} }
async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) { 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 // Data required to identify a message in a conversation
const messageEventData = { const messageEventData = {
pubKey, pubKey,
timestamp: messageTimeStamp, timestamp: messageTimeStamp,
}; };
if (isPublic) {
const { profile } = data;
let displayName = 'Anonymous';
if (profile && profile.displayName) {
({ displayName } = profile);
}
const res = await publicSendData.sendMessage(
data.body,
data.quote,
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 data64 = dcodeIO.ByteBuffer.wrap(data).toString('base64');
const p2pSuccess = await trySendP2p( const p2pSuccess = await trySendP2p(
pubKey, pubKey,

View File

@ -70,7 +70,9 @@ class LokiP2pAPI extends EventEmitter {
} }
getContactP2pDetails(pubKey) { getContactP2pDetails(pubKey) {
if (!this.contactP2pDetails[pubKey]) return null; if (!this.contactP2pDetails[pubKey]) {
return null;
}
return { ...this.contactP2pDetails[pubKey] }; return { ...this.contactP2pDetails[pubKey] };
} }

View File

@ -0,0 +1,672 @@
/* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController,
clearTimeout, MessageController */
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 PUBLICCHAT_MSG_POLL_EVERY = 1.5 * 1000; // 1.5s
const PUBLICCHAT_CHAN_POLL_EVERY = 20 * 1000; // 20s
const PUBLICCHAT_DELETION_POLL_EVERY = 5 * 1000; // 5s
const PUBLICCHAT_MOD_POLL_EVERY = 30 * 1000; // 30s
// 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].baseServerUrl === 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;
}
thisChannel.stop();
this.channels.splice(i, 1);
}
// 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.conversationId = conversationId;
this.conversation = ConversationController.get(conversationId);
this.lastGot = null;
this.modStatus = false;
this.deleteLastId = 1;
this.timers = {};
this.running = true;
// end properties
log.info(`registered LokiPublicChannel ${channelId}`);
// start polling
this.pollForMessages();
this.pollForDeletions();
this.pollForChannel();
this.pollForModerators();
}
stop() {
this.running = false;
if (this.timers.channel) {
clearTimeout(this.timers.channel);
}
if (this.timers.moderator) {
clearTimeout(this.timers.moderator);
}
if (this.timers.delete) {
clearTimeout(this.timers.delete);
}
if (this.timers.message) {
clearTimeout(this.timers.message);
}
}
// 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 moderation actions
async pollForModerators() {
try {
await this.pollOnceForModerators();
} catch (e) {
log.warn(`Error while polling for public chat moderators: ${e}`);
}
if (this.running) {
this.timers.moderator = setTimeout(() => {
this.pollForModerators();
}, PUBLICCHAT_MOD_POLL_EVERY);
}
}
// get moderator status
async pollOnceForModerators() {
// get moderator status
const res = await this.serverRequest(
`loki/v1/channel/${this.channelId}/get_moderators`
);
const ourNumber = textsecure.storage.user.getNumber();
// Get the list of moderators if no errors occurred
const moderators = !res.err && res.response && res.response.moderators;
// if we encountered problems then we'll keep the old mod status
if (moderators) {
this.modStatus = moderators.includes(ourNumber);
}
await this.conversation.setModerators(moderators || []);
// get token info
const tokenRes = await this.serverRequest('token');
// if no problems and we have data
if (
!tokenRes.err &&
tokenRes.response &&
tokenRes.response.data &&
tokenRes.response.data.user
) {
// get our profile name and write it to the network
const profileConvo = ConversationController.get(ourNumber);
const profileName = profileConvo.getProfileName();
// update profile name as needed
if (tokenRes.response.data.user.name !== profileName) {
if (profileName) {
await this.serverRequest('users/me', {
method: 'PATCH',
objBody: {
name: profileName,
},
});
// no big deal if it fails...
// } else {
// should we update the local from the server?
// guessing no because there will be multiple servers
}
// update our avatar if needed
}
}
}
// delete a message on the server
async deleteMessage(serverId, canThrow = false) {
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;
}
// fire an alert
log.warn(`failed to delete ${serverId} on ${this.baseChannelUrl}`);
if (canThrow) {
throw new textsecure.PublicChatError(
'Failed to delete public chat message'
);
}
return false;
}
// used for sending messages
getEndpoint() {
const endpoint = `${this.serverAPI.baseServerUrl}/${
this.baseChannelUrl
}/messages`;
return endpoint;
}
// get moderation actions
async pollForChannel() {
try {
await this.pollForChannelOnce();
} catch (e) {
log.warn(`Error while polling for public chat room details: ${e}`);
}
if (this.running) {
this.timers.channel = setTimeout(() => {
this.pollForChannel();
}, PUBLICCHAT_CHAN_POLL_EVERY);
}
}
// update room details
async pollForChannelOnce() {
const res = await this.serverRequest(`${this.baseChannelUrl}`, {
params: {
include_annotations: 1,
},
});
if (
!res.err &&
res.response &&
res.response.data.annotations &&
res.response.data.annotations.length
) {
res.response.data.annotations.forEach(note => {
if (note.type === 'net.patter-app.settings') {
// note.value.description only needed for directory
if (note.value && note.value.name) {
this.conversation.setGroupName(note.value.name);
}
if (note.value && note.value.avatar) {
this.conversation.setProfileAvatar(note.value.avatar);
}
// else could set a default in case of server problems...
}
});
}
}
// get moderation actions
async pollForDeletions() {
try {
await this.pollOnceForDeletions();
} catch (e) {
log.warn(`Error while polling for public chat deletions: ${e}`);
}
if (this.running) {
this.timers.delete = setTimeout(() => {
this.pollForDeletions();
}, PUBLICCHAT_DELETION_POLL_EVERY);
}
}
async pollOnceForDeletions() {
// 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 }
);
// if any problems, abort out
if (res.err || !res.response) {
if (res.err) {
log.error(`Error ${res.err}`);
}
break;
}
// 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,
});
});
// update where we last checked
this.deleteLastId = res.response.meta.max_id;
more = res.response.meta.more && res.response.data.length >= params.count;
}
}
// get channel messages
async pollForMessages() {
try {
await this.pollOnceForMessages();
} catch (e) {
log.warn(`Error while polling for public chat messages: ${e}`);
}
if (this.running) {
setTimeout(() => {
this.timers.message = this.pollForMessages();
}, PUBLICCHAT_MSG_POLL_EVERY);
}
}
async pollOnceForMessages() {
const params = {
include_annotations: 1,
include_deleted: false,
};
if (!this.conversation) {
log.warn('Trying to poll for non-existing public conversation');
this.lastGot = 0;
} else if (!this.lastGot) {
this.lastGot = this.conversation.getLastRetrievedMessage();
}
params.since_id = this.lastGot;
// Just grab the most recent 100 messages if you don't have a valid lastGot
params.count = this.lastGot === 0 ? -100 : 20;
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();
// pubKey lives in the username field
let from = adnMessage.user.name;
let quote = null;
if (adnMessage.is_deleted) {
return;
}
if (
Array.isArray(adnMessage.annotations) &&
adnMessage.annotations.length !== 0
) {
const noteValue = adnMessage.annotations[0].value;
({ timestamp, quote } = noteValue);
if (quote) {
quote.attachments = [];
}
// if user doesn't have a name set, fallback to annotation
// pubkeys are already there in v1 (first release)
if (!from) {
({ from } = noteValue);
}
}
if (
!from ||
!timestamp ||
!adnMessage.id ||
!adnMessage.user ||
!adnMessage.user.username ||
!adnMessage.text
) {
return; // Invalid message
}
const messageData = {
serverId: adnMessage.id,
friendRequest: false,
source: adnMessage.user.username,
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,
contact: [],
preview: [],
profile: {
displayName: from,
},
},
};
receivedAt += 1; // Ensure different arrival times
this.serverAPI.chatAPI.emit('publicMessage', {
message: messageData,
});
// now process any user meta data updates
// - update their conversation with a potentially new avatar
this.lastGot = !this.lastGot
? adnMessage.id
: Math.max(this.lastGot, adnMessage.id);
});
this.conversation.setLastRetrievedMessage(this.lastGot);
}
}
// create a message in the channel
async sendMessage(text, quote, messageTimeStamp, displayName, pubKey) {
const payload = {
text,
annotations: [
{
type: 'network.loki.messenger.publicChat',
value: {
timestamp: messageTimeStamp,
// will deprecated
from: displayName,
// will deprecated
source: pubKey,
quote,
},
},
],
};
if (quote && quote.id) {
// copied from model/message.js copyFromQuotedMessage
const collection = await Signal.Data.getMessagesBySentAt(quote.id, {
MessageCollection: Whisper.MessageCollection,
});
const found = collection.find(item => {
const messageAuthor = item.getContact();
return messageAuthor && quote.author === messageAuthor.id;
});
if (found) {
const queryMessage = MessageController.register(found.id, found);
const replyTo = queryMessage.get('serverId');
if (replyTo) {
payload.reply_to = replyTo;
}
}
}
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;

140
js/modules/loki_rss_api.js Normal file
View File

@ -0,0 +1,140 @@
/* 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,
});
});
const ref = this;
function callTimer() {
ref.getFeed();
}
this.feedTimer = setTimeout(callTimer, RSS_POLL_EVERY);
}
}
module.exports = LokiRssAPI;

View File

@ -48,8 +48,9 @@ class LokiSnodeAPI {
const upnpClient = natUpnp.createClient(); const upnpClient = natUpnp.createClient();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
upnpClient.externalIp((err, ip) => { upnpClient.externalIp((err, ip) => {
if (err) reject(err); if (err) {
else { reject(err);
} else {
resolve(ip); resolve(ip);
} }
}); });
@ -89,6 +90,7 @@ class LokiSnodeAPI {
async initialiseRandomPool(seedNodes = [...window.seedNodeList]) { async initialiseRandomPool(seedNodes = [...window.seedNodeList]) {
const params = { const params = {
limit: 20, limit: 20,
active_only: true,
fields: { fields: {
public_ip: true, public_ip: true,
storage_port: true, storage_port: true,
@ -134,8 +136,8 @@ class LokiSnodeAPI {
await conversation.updateSwarmNodes(filteredNodes); await conversation.updateSwarmNodes(filteredNodes);
} }
async updateLastHash(nodeUrl, lastHash, expiresAt) { async updateLastHash(snode, hash, expiresAt) {
await window.Signal.Data.updateLastHash({ nodeUrl, lastHash, expiresAt }); await window.Signal.Data.updateLastHash({ snode, hash, expiresAt });
} }
getSwarmNodesForPubKey(pubKey) { getSwarmNodesForPubKey(pubKey) {

Binary file not shown.

View File

@ -196,6 +196,10 @@
const dialog = new Whisper.SeedDialogView({ seed }); const dialog = new Whisper.SeedDialogView({ seed });
this.el.append(dialog.el); this.el.append(dialog.el);
}, },
showQRDialog(string) {
const dialog = new Whisper.QRDialogView({ string });
this.el.append(dialog.el);
},
showDevicePairingDialog() { showDevicePairingDialog() {
const dialog = new Whisper.DevicePairingDialogView(); const dialog = new Whisper.DevicePairingDialogView();
// remove all listeners for this events is fine since the // remove all listeners for this events is fine since the

View File

@ -39,7 +39,9 @@
}, },
onUnblock() { onUnblock() {
const number = this.$('select option:selected').val(); const number = this.$('select option:selected').val();
if (!number) return; if (!number) {
return;
}
if (BlockedNumberController.isBlocked(number)) { if (BlockedNumberController.isBlocked(number)) {
BlockedNumberController.unblock(number); BlockedNumberController.unblock(number);
@ -73,7 +75,9 @@
}, },
truncate(string, limit) { truncate(string, limit) {
// Make sure an element and number of items to truncate is provided // Make sure an element and number of items to truncate is provided
if (!string || !limit) return string; if (!string || !limit) {
return string;
}
// Get the inner content of the element // Get the inner content of the element
let content = string.trim(); let content = string.trim();
@ -84,7 +88,9 @@
// Convert the array of words back into a string // Convert the array of words back into a string
// If there's content to add after it, add it // If there's content to add after it, add it
if (string.length > limit) content = `${content}...`; if (string.length > limit) {
content = `${content}...`;
}
return content; return content;
}, },

View File

@ -201,10 +201,12 @@
isVerified: this.model.isVerified(), isVerified: this.model.isVerified(),
isKeysPending: !this.model.isFriend(), isKeysPending: !this.model.isFriend(),
isMe: this.model.isMe(), isMe: this.model.isMe(),
isClosable: this.model.isClosable(),
isBlocked: this.model.isBlocked(), isBlocked: this.model.isBlocked(),
isGroup: !this.model.isPrivate(), isGroup: !this.model.isPrivate(),
isOnline: this.model.isOnline(), isOnline: this.model.isOnline(),
isArchived: this.model.get('isArchived'), isArchived: this.model.get('isArchived'),
isPublic: this.model.isPublic(),
expirationSettingName, expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length), showBackButton: Boolean(this.panels && this.panels.length),
@ -389,7 +391,9 @@
}, },
onChangePlaceholder(type) { onChangePlaceholder(type) {
if (!this.$messageField) return; if (!this.$messageField) {
return;
}
let placeholder; let placeholder;
switch (type) { switch (type) {
case 'friend-request': case 'friend-request':
@ -1290,15 +1294,27 @@
}, },
deleteMessage(message) { deleteMessage(message) {
const warningMessage = this.model.isPublic()
? i18n('deletePublicWarning')
: i18n('deleteWarning');
const dialog = new Whisper.ConfirmationDialogView({ const dialog = new Whisper.ConfirmationDialogView({
message: i18n('deleteWarning'), message: warningMessage,
okText: i18n('delete'), okText: i18n('delete'),
resolve: () => { resolve: async () => {
window.Signal.Data.removeMessage(message.id, { if (this.model.isPublic()) {
const success = await this.model.deletePublicMessage(message);
if (!success) {
// Message failed to delete from server, show error?
return;
}
} else {
this.model.messageCollection.remove(message.id);
}
await window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message, Message: Whisper.Message,
}); });
message.trigger('unload'); message.trigger('unload');
this.model.messageCollection.remove(message.id);
this.resetPanel(); this.resetPanel();
this.updateHeader(); this.updateHeader();
}, },
@ -1486,8 +1502,12 @@
}, },
destroyMessages() { destroyMessages() {
const message = this.model.isPublic()
? i18n('deletePublicConversationConfirmation')
: i18n('deleteConversationConfirmation');
Whisper.events.trigger('showConfirmationDialog', { Whisper.events.trigger('showConfirmationDialog', {
message: i18n('deleteConversationConfirmation'), message,
onOk: async () => { onOk: async () => {
try { try {
await this.model.destroyMessages(); await this.model.destroyMessages();

View File

@ -0,0 +1,47 @@
/* global Whisper, i18n, QRCode */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.QRDialogView = Whisper.View.extend({
templateName: 'qr-code-template',
className: 'loki-dialog qr-dialog modal',
initialize(options = {}) {
this.okText = options.okText || i18n('ok');
this.render();
this.$('.qr-dialog').bind('keyup', event => this.onKeyup(event));
if (options.string) {
this.qr = new QRCode(this.$('#qr')[0], {
correctLevel: QRCode.CorrectLevel.L,
}).makeCode(options.string);
this.$('#qr').addClass('ready');
}
},
events: {
'click .ok': 'close',
},
render_attributes() {
return {
ok: this.okText,
};
},
close() {
this.remove();
},
onKeyup(event) {
switch (event.key) {
case 'Enter':
case 'Escape':
case 'Esc':
this.close();
break;
default:
break;
}
},
});
})();

View File

@ -14,7 +14,9 @@
); );
await Promise.all( await Promise.all(
friendKeys.map(async pubKey => { friendKeys.map(async pubKey => {
if (pubKey === textsecure.storage.user.getNumber()) return; if (pubKey === textsecure.storage.user.getNumber()) {
return;
}
try { try {
await sendOnlineBroadcastMessage(pubKey); await sendOnlineBroadcastMessage(pubKey);
} catch (e) { } catch (e) {

View File

@ -34,8 +34,8 @@
async function DHDecrypt(symmetricKey, ivAndCiphertext) { async function DHDecrypt(symmetricKey, ivAndCiphertext) {
const iv = ivAndCiphertext.slice(0, IV_LENGTH); const iv = ivAndCiphertext.slice(0, IV_LENGTH);
const cipherText = ivAndCiphertext.slice(IV_LENGTH); const ciphertext = ivAndCiphertext.slice(IV_LENGTH);
return libsignal.crypto.decrypt(symmetricKey, cipherText, iv); return libsignal.crypto.decrypt(symmetricKey, ciphertext, iv);
} }
class FallBackSessionCipher { class FallBackSessionCipher {
@ -131,18 +131,18 @@
return this._ephemeralPubKeyHex; return this._ephemeralPubKeyHex;
} }
async decrypt(snodeAddress, ivAndCipherTextBase64) { async decrypt(snodeAddress, ivAndCiphertextBase64) {
const ivAndCipherText = dcodeIO.ByteBuffer.wrap( const ivAndCiphertext = dcodeIO.ByteBuffer.wrap(
ivAndCipherTextBase64, ivAndCiphertextBase64,
'base64' 'base64'
).toArrayBuffer(); ).toArrayBuffer();
const symmetricKey = await this._getSymmetricKey(snodeAddress); const symmetricKey = await this._getSymmetricKey(snodeAddress);
try { try {
const decrypted = await DHDecrypt(symmetricKey, ivAndCipherText); const decrypted = await DHDecrypt(symmetricKey, ivAndCiphertext);
const decoder = new TextDecoder(); const decoder = new TextDecoder();
return decoder.decode(decrypted); return decoder.decode(decrypted);
} catch (e) { } catch (e) {
return ivAndCipherText; return ivAndCiphertext;
} }
} }
@ -153,8 +153,8 @@
plainText = textEncoder.encode(plainText); plainText = textEncoder.encode(plainText);
} }
const symmetricKey = await this._getSymmetricKey(snodeAddress); const symmetricKey = await this._getSymmetricKey(snodeAddress);
const cipherText = await DHEncrypt(symmetricKey, plainText); const ciphertext = await DHEncrypt(symmetricKey, plainText);
return dcodeIO.ByteBuffer.wrap(cipherText).toString('base64'); return dcodeIO.ByteBuffer.wrap(ciphertext).toString('base64');
} }
} }
@ -204,7 +204,25 @@
// Throws for invalid signature // Throws for invalid signature
await libsignal.Curve.async.verifySignature(issuer, data.buffer, signature); await libsignal.Curve.async.verifySignature(issuer, data.buffer, signature);
} }
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(); const snodeCipher = new LokiSnodeChannel();
window.libloki.crypto = { window.libloki.crypto = {
@ -213,6 +231,7 @@
FallBackSessionCipher, FallBackSessionCipher,
FallBackDecryptionError, FallBackDecryptionError,
snodeCipher, snodeCipher,
  decryptToken,
generateSignatureForPairing, generateSignatureForPairing,
verifyPairingAuthorisation, verifyPairingAuthorisation,
// for testing // for testing

View File

@ -148,8 +148,11 @@ class LocalLokiServer extends EventEmitter {
ttl, ttl,
}, },
err => { err => {
if (err) reject(err); if (err) {
else resolve(); reject(err);
} else {
resolve();
}
} }
); );
}); });

View File

@ -46,11 +46,17 @@ const pow = {
// Compare two Uint8Arrays, return true if arr1 is > arr2 // Compare two Uint8Arrays, return true if arr1 is > arr2
greaterThan(arr1, arr2) { greaterThan(arr1, arr2) {
// Early exit if lengths are not equal. Should never happen // Early exit if lengths are not equal. Should never happen
if (arr1.length !== arr2.length) return false; if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0, len = arr1.length; i < len; i += 1) { for (let i = 0, len = arr1.length; i < len; i += 1) {
if (arr1[i] > arr2[i]) return true; if (arr1[i] > arr2[i]) {
if (arr1[i] < arr2[i]) return false; return true;
}
if (arr1[i] < arr2[i]) {
return false;
}
} }
return false; return false;
}, },

View File

@ -263,6 +263,16 @@
} }
} }
function PublicTokenError(message) {
this.name = 'PublicTokenError';
ReplayableError.call(this, {
name: 'PublicTokenError',
message,
});
}
inherit(ReplayableError, PublicTokenError);
function TimestampError(message) { function TimestampError(message) {
this.name = 'TimeStampError'; this.name = 'TimeStampError';
@ -273,6 +283,18 @@
} }
inherit(ReplayableError, TimestampError); 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.UnregisteredUserError = UnregisteredUserError;
window.textsecure.SendMessageNetworkError = SendMessageNetworkError; window.textsecure.SendMessageNetworkError = SendMessageNetworkError;
window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError; window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError;
@ -292,4 +314,6 @@
window.textsecure.WrongSwarmError = WrongSwarmError; window.textsecure.WrongSwarmError = WrongSwarmError;
window.textsecure.WrongDifficultyError = WrongDifficultyError; window.textsecure.WrongDifficultyError = WrongDifficultyError;
window.textsecure.TimestampError = TimestampError; window.textsecure.TimestampError = TimestampError;
window.textsecure.PublicChatError = PublicChatError;
window.textsecure.PublicTokenError = PublicTokenError;
})(); })();

View File

@ -51,8 +51,9 @@ window.textsecure.utils = (() => {
*** JSON'ing Utilities *** *** JSON'ing Utilities ***
************************* */ ************************* */
function ensureStringed(thing) { function ensureStringed(thing) {
if (getStringable(thing)) return getString(thing); if (getStringable(thing)) {
else if (thing instanceof Array) { return getString(thing);
} else if (thing instanceof Array) {
const res = []; const res = [];
for (let i = 0; i < thing.length; i += 1) { for (let i = 0; i < thing.length; i += 1) {
res[i] = ensureStringed(thing[i]); res[i] = ensureStringed(thing[i]);
@ -60,7 +61,9 @@ window.textsecure.utils = (() => {
return res; return res;
} else if (thing === Object(thing)) { } else if (thing === Object(thing)) {
const res = {}; const res = {};
for (const key in thing) res[key] = ensureStringed(thing[key]); for (const key in thing) {
res[key] = ensureStringed(thing[key]);
}
return res; return res;
} else if (thing === null) { } else if (thing === null) {
return null; return null;

View File

@ -33,7 +33,9 @@ window.textsecure.storage.impl = {
*** Override Storage Routines *** *** Override Storage Routines ***
**************************** */ **************************** */
put(key, value) { put(key, value) {
if (value === undefined) throw new Error('Tried to store undefined'); if (value === undefined) {
throw new Error('Tried to store undefined');
}
store[key] = value; store[key] = value;
postMessage({ method: 'set', key, value }); postMessage({ method: 'set', key, value });
}, },

View File

@ -13,9 +13,11 @@
/* global GroupBuffer: false */ /* global GroupBuffer: false */
/* global WebSocketResource: false */ /* global WebSocketResource: false */
/* global localLokiServer: false */ /* global localLokiServer: false */
/* global lokiPublicChatAPI: false */
/* global localServerPort: false */ /* global localServerPort: false */
/* global lokiMessageAPI: false */ /* global lokiMessageAPI: false */
/* global lokiP2pAPI: false */ /* global lokiP2pAPI: false */
/* global feeds: false */
/* global Whisper: false */ /* global Whisper: false */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -76,6 +78,14 @@ MessageReceiver.prototype.extend({
}); });
this.httpPollingResource.pollServer(); this.httpPollingResource.pollServer();
localLokiServer.on('message', this.handleP2pMessage.bind(this)); 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(); this.startLocalServer();
// TODO: Rework this socket stuff to work with online messaging // TODO: Rework this socket stuff to work with online messaging
@ -143,6 +153,12 @@ MessageReceiver.prototype.extend({
}; };
this.httpPollingResource.handleMessage(message, options); this.httpPollingResource.handleMessage(message, options);
}, },
handleUnencryptedMessage({ message }) {
const ev = new Event('message');
ev.confirm = function confirmTerm() {};
ev.data = message;
this.dispatchAndWait(ev);
},
stopProcessing() { stopProcessing() {
window.log.info('MessageReceiver: stopProcessing requested'); window.log.info('MessageReceiver: stopProcessing requested');
this.stoppingProcessing = true; this.stoppingProcessing = true;
@ -715,9 +731,13 @@ MessageReceiver.prototype.extend({
} }
const getCurrentSessionBaseKey = async () => { const getCurrentSessionBaseKey = async () => {
const record = await sessionCipher.getRecord(address.toString()); const record = await sessionCipher.getRecord(address.toString());
if (!record) return null; if (!record) {
return null;
}
const openSession = record.getOpenSession(); const openSession = record.getOpenSession();
if (!openSession) return null; if (!openSession) {
return null;
}
const { baseKey } = openSession.indexInfo; const { baseKey } = openSession.indexInfo;
return baseKey; return baseKey;
}; };
@ -726,7 +746,9 @@ MessageReceiver.prototype.extend({
}; };
const restoreActiveSession = async () => { const restoreActiveSession = async () => {
const record = await sessionCipher.getRecord(address.toString()); const record = await sessionCipher.getRecord(address.toString());
if (!record) return; if (!record) {
return;
}
record.archiveCurrentState(); record.archiveCurrentState();
const sessionToRestore = record.sessions[this.activeSessionBaseKey]; const sessionToRestore = record.sessions[this.activeSessionBaseKey];
record.promoteState(sessionToRestore); record.promoteState(sessionToRestore);
@ -738,7 +760,9 @@ MessageReceiver.prototype.extend({
}; };
const deleteAllSessionExcept = async sessionBaseKey => { const deleteAllSessionExcept = async sessionBaseKey => {
const record = await sessionCipher.getRecord(address.toString()); const record = await sessionCipher.getRecord(address.toString());
if (!record) return; if (!record) {
return;
}
const sessionToKeep = record.sessions[sessionBaseKey]; const sessionToKeep = record.sessions[sessionBaseKey];
record.sessions = {}; record.sessions = {};
record.updateSessionState(sessionToKeep); record.updateSessionState(sessionToKeep);

View File

@ -43,9 +43,19 @@ function OutgoingMessage(
this.failoverNumbers = []; this.failoverNumbers = [];
this.unidentifiedDeliveries = []; this.unidentifiedDeliveries = [];
const { numberInfo, senderCertificate, online, messageType, isPing } = const {
numberInfo,
senderCertificate,
online,
messageType,
isPing,
isPublic,
publicSendData,
} =
options || {}; options || {};
this.numberInfo = numberInfo; this.numberInfo = numberInfo;
this.isPublic = isPublic;
this.publicSendData = publicSendData;
this.senderCertificate = senderCertificate; this.senderCertificate = senderCertificate;
this.online = online; this.online = online;
this.messageType = messageType || 'outgoing'; this.messageType = messageType || 'outgoing';
@ -195,6 +205,10 @@ OutgoingMessage.prototype = {
numConnections: NUM_SEND_CONNECTIONS, numConnections: NUM_SEND_CONNECTIONS,
isPing: this.isPing, isPing: this.isPing,
}; };
options.isPublic = this.isPublic;
if (this.isPublic) {
options.publicSendData = this.publicSendData;
}
await lokiMessageAPI.sendMessage(pubKey, data, timestamp, ttl, options); await lokiMessageAPI.sendMessage(pubKey, data, timestamp, ttl, options);
} catch (e) { } catch (e) {
if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) {
@ -261,6 +275,21 @@ OutgoingMessage.prototype = {
}, },
doSendMessage(number, devicesPubKeys, recurse) { doSendMessage(number, devicesPubKeys, recurse) {
const ciphers = {}; 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;
});
}
this.numbers = devicesPubKeys; this.numbers = devicesPubKeys;

View File

@ -14,13 +14,17 @@
*** Base Storage Routines *** *** Base Storage Routines ***
**************************** */ **************************** */
put(key, value) { put(key, value) {
if (value === undefined) throw new Error('Tried to store undefined'); if (value === undefined) {
throw new Error('Tried to store undefined');
}
localStorage.setItem(`${key}`, textsecure.utils.jsonThing(value)); localStorage.setItem(`${key}`, textsecure.utils.jsonThing(value));
}, },
get(key, defaultValue) { get(key, defaultValue) {
const value = localStorage.getItem(`${key}`); const value = localStorage.getItem(`${key}`);
if (value === null) return defaultValue; if (value === null) {
return defaultValue;
}
return JSON.parse(value); return JSON.parse(value);
}, },

View File

@ -18,13 +18,17 @@
getNumber() { getNumber() {
const numberId = textsecure.storage.get('number_id'); const numberId = textsecure.storage.get('number_id');
if (numberId === undefined) return undefined; if (numberId === undefined) {
return undefined;
}
return textsecure.utils.unencodeNumber(numberId)[0]; return textsecure.utils.unencodeNumber(numberId)[0];
}, },
getDeviceId() { getDeviceId() {
const numberId = textsecure.storage.get('number_id'); const numberId = textsecure.storage.get('number_id');
if (numberId === undefined) return undefined; if (numberId === undefined) {
return undefined;
}
return textsecure.utils.unencodeNumber(numberId)[1]; return textsecure.utils.unencodeNumber(numberId)[1];
}, },

View File

@ -171,7 +171,9 @@ SignalProtocolStore.prototype = {
async loadPreKeyForContact(contactPubKey) { async loadPreKeyForContact(contactPubKey) {
return new Promise(resolve => { return new Promise(resolve => {
const key = this.get(`25519KeypreKey${contactPubKey}`); const key = this.get(`25519KeypreKey${contactPubKey}`);
if (!key) resolve(undefined); if (!key) {
resolve(undefined);
}
resolve({ resolve({
pubKey: key.publicKey, pubKey: key.publicKey,
privKey: key.privateKey, privKey: key.privateKey,

View File

@ -3,7 +3,7 @@
"productName": "Loki Messenger", "productName": "Loki Messenger",
"description": "Private messaging from your desktop", "description": "Private messaging from your desktop",
"repository": "https://github.com/loki-project/loki-messenger.git", "repository": "https://github.com/loki-project/loki-messenger.git",
"version": "1.0.0-beta3", "version": "1.0.0-beta5",
"license": "GPL-3.0", "license": "GPL-3.0",
"author": { "author": {
"name": "Loki Project", "name": "Loki Project",
@ -14,13 +14,13 @@
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider", "postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
"start": "electron .", "start": "electron .",
"start-multi": "NODE_APP_INSTANCE=1 electron .", "start-multi": "NODE_APP_INSTANCE=1 electron .",
"start-prod": "LOKI_DEV=1 electron .", "start-prod": "NODE_ENV=production NODE_APP_INSTANCE=devprod LOKI_DEV=1 electron .",
"start-prod-multi": "LOKI_DEV=1 NODE_APP_INSTANCE=1 electron .", "start-prod-multi": "NODE_ENV=production NODE_APP_INSTANCE=devprod1 LOKI_DEV=1 electron .",
"grunt": "grunt", "grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build", "icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt", "generate": "yarn icon-gen && yarn grunt",
"build": "build --config.extraMetadata.environment=$SIGNAL_ENV", "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", "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", "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", "clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
@ -60,8 +60,9 @@
"buffer-crc32": "0.2.13", "buffer-crc32": "0.2.13",
"bunyan": "1.8.12", "bunyan": "1.8.12",
"classnames": "2.2.5", "classnames": "2.2.5",
"color": "^3.1.2",
"config": "1.28.1", "config": "1.28.1",
"electron-context-menu": "^0.11.0", "electron-context-menu": "^0.15.0",
"electron-editor-context-menu": "1.1.1", "electron-editor-context-menu": "1.1.1",
"electron-is-dev": "0.3.0", "electron-is-dev": "0.3.0",
"emoji-datasource": "4.0.0", "emoji-datasource": "4.0.0",
@ -76,7 +77,6 @@
"google-libphonenumber": "3.2.2", "google-libphonenumber": "3.2.2",
"got": "8.2.0", "got": "8.2.0",
"he": "1.2.0", "he": "1.2.0",
"identicon.js": "2.3.3",
"intl-tel-input": "12.1.15", "intl-tel-input": "12.1.15",
"jquery": "3.3.1", "jquery": "3.3.1",
"js-sha512": "0.8.0", "js-sha512": "0.8.0",
@ -122,6 +122,7 @@
"devDependencies": { "devDependencies": {
"@types/chai": "4.1.2", "@types/chai": "4.1.2",
"@types/classnames": "2.2.3", "@types/classnames": "2.2.3",
"@types/color": "^3.0.0",
"@types/config": "0.0.34", "@types/config": "0.0.34",
"@types/filesize": "3.6.0", "@types/filesize": "3.6.0",
"@types/fs-extra": "5.0.5", "@types/fs-extra": "5.0.5",

View File

@ -324,6 +324,10 @@ window.LokiP2pAPI = require('./js/modules/loki_p2p_api');
window.LokiMessageAPI = require('./js/modules/loki_message_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.LocalLokiServer = require('./libloki/modules/local_loki_server');
window.localServerPort = config.localServerPort; window.localServerPort = config.localServerPort;
@ -398,14 +402,25 @@ window.Signal.Logs = require('./js/modules/logs');
// Add right-click listener for selected text and urls // Add right-click listener for selected text and urls
const contextMenu = require('electron-context-menu'); const contextMenu = require('electron-context-menu');
const isQR = params =>
params.mediaType === 'image' && params.titleText === 'Scan me!';
// QR saving doesn't work so we just disable it
contextMenu({ contextMenu({
showInspectElement: false, showInspectElement: false,
shouldShowMenu: (event, params) => shouldShowMenu: (event, params) => {
Boolean( const isRegular =
!params.isEditable && params.mediaType === 'none' && (params.linkURL || params.selectionText);
params.mediaType === 'none' && return Boolean(!params.isEditable && (isQR(params) || isRegular));
(params.linkURL || params.selectionText) },
), menu: (actions, params) => {
// If it's not a QR then show the default options
if (!isQR(params)) {
return actions;
}
return [actions.copyImage()];
},
}); });
// We pull this in last, because the native module involved appears to be sensitive to // We pull this in last, because the native module involved appears to be sensitive to

View File

@ -383,6 +383,7 @@
border: 1px solid $color-loki-green; border: 1px solid $color-loki-green;
color: white; color: white;
outline: none; outline: none;
user-select: none;
&:hover, &:hover,
&:disabled { &:disabled {

View File

@ -867,3 +867,17 @@ $loading-height: 16px;
.inbox { .inbox {
position: relative; position: relative;
} }
.qr-dialog {
.content {
width: 300px !important;
max-width: none !important;
min-width: auto !important;
}
#qr {
display: flex;
justify-content: center;
margin-bottom: 1em;
}
}

View File

@ -20,6 +20,10 @@
font-style: italic; font-style: italic;
} }
.module-contact-name.compact {
display: block;
}
// Module: Message // Module: Message
.module-message { .module-message {
@ -510,23 +514,25 @@
} }
.module-message__metadata__date, .module-message__metadata__date,
.module-message__metadata__p2p { .module-message__metadata__badge {
font-size: 11px; font-size: 11px;
line-height: 16px; line-height: 16px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
color: $color-gray-60; color: $color-gray-60;
text-transform: uppercase; text-transform: uppercase;
} }
.module-message__metadata__date--incoming,
.module-message__metadata__p2p--incoming { .module-message__metadata__badge {
color: $color-white-08; font-weight: bold;
}
.module-message__metadata__date--with-image-no-caption {
color: $color-white;
} }
.module-message__metadata__p2p { .module-message__metadata__date--incoming,
font-weight: bold; .module-message__metadata__badge--incoming {
color: $color-white-08;
}
.module-message__metadata__date--with-image-no-caption {
color: $color-white;
} }
.module-message__metadata__spacer { .module-message__metadata__spacer {
@ -2051,6 +2057,25 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.module-avatar__icon--crown-wrapper {
position: absolute;
bottom: 0;
right: 0;
height: 21px;
width: 21px;
transform: translate(25%, 25%);
padding: 9%;
background-color: $color-white;
border-radius: 50%;
filter: drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3));
}
.module-avatar__icon--crown {
@include color-svg('../images/crown.svg', #ffb000);
height: 100%;
width: 100%;
}
.module-avatar__icon--group { .module-avatar__icon--group {
@include color-svg('../images/profile-group.svg', $color-white); @include color-svg('../images/profile-group.svg', $color-white);
} }
@ -3251,6 +3276,7 @@
.module-left-pane__list { .module-left-pane__list {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
overflow-y: auto;
} }
.module-left-pane__virtual-list { .module-left-pane__virtual-list {

View File

@ -1325,6 +1325,10 @@ body.dark-theme {
background-color: $color-gray-05; background-color: $color-gray-05;
} }
.module-avatar__icon--crown-wrapper {
background-color: $color-gray-75;
}
.module-avatar--no-image { .module-avatar--no-image {
background-color: $color-conversation-grey-shade; background-color: $color-conversation-grey-shade;
} }

View File

@ -566,6 +566,7 @@
<script type='text/javascript' src='../js/views/nickname_dialog_view.js' data-cover></script> <script type='text/javascript' src='../js/views/nickname_dialog_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/password_dialog_view.js' data-cover></script> <script type='text/javascript' src='../js/views/password_dialog_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/seed_dialog_view.js' data-cover></script> <script type='text/javascript' src='../js/views/seed_dialog_view.js' data-cover></script>
<script type='text/javascript' src='js/views/qr_dialog_view.js'></script>
<script type='text/javascript' src='../js/views/identicon_svg_view.js' data-cover></script> <script type='text/javascript' src='../js/views/identicon_svg_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/last_seen_indicator_view.js' data-cover></script> <script type='text/javascript' src='../js/views/last_seen_indicator_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script> <script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>

View File

@ -192,7 +192,7 @@ describe('Link previews', () => {
); );
}); });
it('returns html-decoded tag contents from Instagram', () => { it('returns html-decoded tag contents from Imgur', () => {
const imgur = ` const imgur = `
<meta property="og:site_name" content="Imgur"> <meta property="og:site_name" content="Imgur">
<meta property="og:url" content="https://imgur.com/gallery/KFCL8fm"> <meta property="og:url" content="https://imgur.com/gallery/KFCL8fm">
@ -211,6 +211,50 @@ describe('Link previews', () => {
); );
}); });
it('returns html-decoded tag contents from Giphy', () => {
const giphy = `
<meta property="og:site_name" content="GIPHY">
<meta property="og:url" content="https://media.giphy.com/media/3o7qE8mq5bT9FQj7j2/giphy.gif">
<meta property="og:type" content="video.other">
<meta property="og:title" content="I Cant Hear You Kobe Bryant GIF - Find & Share on GIPHY">
<meta property="og:description" content="Discover &amp; share this Kobe GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.">
<meta property="og:image" content="https://media.giphy.com/media/3o7qE8mq5bT9FQj7j2/giphy.gif">
<meta property="og:image:width" content="480">
<meta property="og:image:height" content="262">
`;
assert.strictEqual(
'I Cant Hear You Kobe Bryant GIF - Find & Share on GIPHY',
getTitleMetaTag(giphy)
);
assert.strictEqual(
'https://media.giphy.com/media/3o7qE8mq5bT9FQj7j2/giphy.gif',
getImageMetaTag(giphy)
);
});
it('returns html-decoded tag contents from Tenor', () => {
const tenor = `
<meta class="dynamic" property="og:site_name" content="Tenor" >
<meta class="dynamic" property="og:url" content="https://media1.tenor.com/images/3772949a5b042e626d259f313fd1e9b8/tenor.gif?itemid=14834517">
<meta class="dynamic" property="og:type" content="video.other">
<meta class="dynamic" property="og:title" content="Hopping Jumping GIF - Hopping Jumping Bird - Discover & Share GIFs">
<meta class="dynamic" property="og:description" content="Click to view the GIF">
<meta class="dynamic" property="og:image" content="https://media1.tenor.com/images/3772949a5b042e626d259f313fd1e9b8/tenor.gif?itemid=14834517">
<meta class="dynamic" property="og:image:width" content="498">
<meta class="dynamic" property="og:image:height" content="435">
`;
assert.strictEqual(
'Hopping Jumping GIF - Hopping Jumping Bird - Discover & Share GIFs',
getTitleMetaTag(tenor)
);
assert.strictEqual(
'https://media1.tenor.com/images/3772949a5b042e626d259f313fd1e9b8/tenor.gif?itemid=14834517',
getImageMetaTag(tenor)
);
});
it('returns only the first tag', () => { it('returns only the first tag', () => {
const html = ` const html = `
<meta property="og:title" content="First&nbsp;Second&nbsp;Third"><meta property="og:title" content="Fourth&nbsp;Fifth&nbsp;Sixth"> <meta property="og:title" content="First&nbsp;Second&nbsp;Third"><meta property="og:title" content="Fourth&nbsp;Fifth&nbsp;Sixth">
@ -229,6 +273,17 @@ describe('Link previews', () => {
getTitleMetaTag(html) getTitleMetaTag(html)
); );
}); });
it('converts image url protocol http to https', () => {
const html = `
<meta property="og:image" content="http://giphygifs.s3.amazonaws.com/media/APcFiiTrG0x2/200.gif">
`;
assert.strictEqual(
'https://giphygifs.s3.amazonaws.com/media/APcFiiTrG0x2/200.gif',
getImageMetaTag(html)
);
});
}); });
describe('#findLinks', () => { describe('#findLinks', () => {

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { JazzIcon } from './JazzIcon';
import { getInitials } from '../util/getInitials'; import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
@ -22,7 +23,7 @@ interface State {
imageBroken: boolean; imageBroken: boolean;
} }
export class Avatar extends React.Component<Props, State> { export class Avatar extends React.PureComponent<Props, State> {
public handleImageErrorBound: () => void; public handleImageErrorBound: () => void;
public constructor(props: Props) { public constructor(props: Props) {
@ -43,6 +44,22 @@ export class Avatar extends React.Component<Props, State> {
}); });
} }
public renderIdenticon() {
const { phoneNumber, borderColor, borderWidth, size } = this.props;
if (!phoneNumber) {
return this.renderNoImage();
}
const borderStyle = this.getBorderStyle(borderColor, borderWidth);
// Generate the seed
const hash = phoneNumber.substring(0, 12);
const seed = parseInt(hash, 16) || 1234;
return <JazzIcon seed={seed} diameter={size} paperStyles={borderStyle} />;
}
public renderImage() { public renderImage() {
const { const {
avatarPath, avatarPath,
@ -129,10 +146,18 @@ export class Avatar extends React.Component<Props, State> {
} }
public render() { public render() {
const { avatarPath, color, size, noteToSelf } = this.props; const {
avatarPath,
color,
size,
noteToSelf,
conversationType,
} = this.props;
const { imageBroken } = this.state; const { imageBroken } = this.state;
const hasImage = !noteToSelf && avatarPath && !imageBroken; // If it's a direct conversation then we must have an identicon
const hasAvatar = avatarPath || conversationType === 'direct';
const hasImage = !noteToSelf && hasAvatar && !imageBroken;
if (size !== 28 && size !== 36 && size !== 48 && size !== 80) { if (size !== 28 && size !== 36 && size !== 48 && size !== 80) {
throw new Error(`Size ${size} is not supported!`); throw new Error(`Size ${size} is not supported!`);
@ -147,11 +172,22 @@ export class Avatar extends React.Component<Props, State> {
!hasImage ? `module-avatar--${color}` : null !hasImage ? `module-avatar--${color}` : null
)} )}
> >
{hasImage ? this.renderImage() : this.renderNoImage()} {hasImage ? this.renderAvatarOrIdenticon() : this.renderNoImage()}
</div> </div>
); );
} }
private renderAvatarOrIdenticon() {
const { avatarPath, conversationType } = this.props;
// If it's a direct conversation then we must have an identicon
const hasAvatar = avatarPath || conversationType === 'direct';
return hasAvatar && avatarPath
? this.renderImage()
: this.renderIdenticon();
}
private getBorderStyle(color?: string, width?: number) { private getBorderStyle(color?: string, width?: number) {
const borderWidth = typeof width === 'number' ? width : 3; const borderWidth = typeof width === 'number' ? width : 3;

View File

@ -21,6 +21,8 @@ export type PropsData = {
type: 'group' | 'direct'; type: 'group' | 'direct';
avatarPath?: string; avatarPath?: string;
isMe: boolean; isMe: boolean;
isPublic?: boolean;
isClosable?: boolean;
lastUpdated: number; lastUpdated: number;
unreadCount: number; unreadCount: number;
@ -30,6 +32,7 @@ export type PropsData = {
lastMessage?: { lastMessage?: {
status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
text: string; text: string;
isRss: boolean;
}; };
showFriendRequestIndicator?: boolean; showFriendRequestIndicator?: boolean;
@ -162,6 +165,8 @@ export class ConversationListItem extends React.PureComponent<Props> {
i18n, i18n,
isBlocked, isBlocked,
isMe, isMe,
isClosable,
isPublic,
hasNickname, hasNickname,
onDeleteContact, onDeleteContact,
onDeleteMessages, onDeleteMessages,
@ -177,21 +182,31 @@ export class ConversationListItem extends React.PureComponent<Props> {
return ( return (
<ContextMenu id={triggerId}> <ContextMenu id={triggerId}>
{!isMe ? ( {!isPublic && !isMe ? (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem> <MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
) : null} ) : null}
{!isMe ? ( {!isPublic && !isMe ? (
<MenuItem onClick={onChangeNickname}> <MenuItem onClick={onChangeNickname}>
{i18n('changeNickname')} {i18n('changeNickname')}
</MenuItem> </MenuItem>
) : null} ) : null}
{!isMe && hasNickname ? ( {!isPublic && !isMe && hasNickname ? (
<MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem> <MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
) : null} ) : null}
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem> {!isPublic ? (
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem>
) : null}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem> <MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{!isMe ? ( {!isMe && isClosable ? (
<MenuItem onClick={onDeleteContact}>{i18n('deleteContact')}</MenuItem> !isPublic ? (
<MenuItem onClick={onDeleteContact}>
{i18n('deleteContact')}
</MenuItem>
) : (
<MenuItem onClick={onDeleteContact}>
{i18n('deletePublicChannel')}
</MenuItem>
)
) : null} ) : null}
</ContextMenu> </ContextMenu>
); );
@ -213,7 +228,13 @@ export class ConversationListItem extends React.PureComponent<Props> {
if (!lastMessage && !isTyping) { if (!lastMessage && !isTyping) {
return null; 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)) { if (isEmpty(text)) {
return null; return null;

View File

@ -0,0 +1,166 @@
// Modified from https://github.com/redlanta/react-jazzicon
import React from 'react';
import Color from 'color';
import { Paper } from './Paper';
import { RNG } from './RNG';
const defaultColors = [
'#01888c', // teal
'#fc7500', // bright orange
'#034f5d', // dark teal
'#E784BA', // light pink
'#81C8B6', // bright green
'#c7144c', // raspberry
'#f3c100', // goldenrod
'#1598f2', // lightning blue
'#2465e1', // sail blue
'#f19e02', // gold
];
const isColor = (str: string) => /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(str);
const isColors = (arr: Array<string>) => {
if (!Array.isArray(arr)) {
return false;
}
if (arr.every(value => typeof value === 'string' && isColor(value))) {
return true;
}
return false;
};
interface Props {
diameter: number;
seed: number;
paperStyles?: Object;
svgStyles?: Object;
shapeCount?: number;
wobble?: number;
colors?: Array<string>;
}
// tslint:disable-next-line no-http-string
const svgns = 'http://www.w3.org/2000/svg';
const shapeCount = 4;
const wobble = 30;
export class JazzIcon extends React.PureComponent<Props> {
public render() {
const {
colors: customColors,
diameter,
paperStyles,
seed,
svgStyles,
} = this.props;
const generator = new RNG(seed);
const colors = customColors || defaultColors;
const newColours = this.hueShift(
this.colorsForIcon(colors).slice(),
generator
);
const shapesArr = Array(shapeCount).fill(null);
const shuffledColours = this.shuffleArray(newColours, generator);
return (
<Paper color={shuffledColours[0]} diameter={diameter} style={paperStyles}>
<svg
xmlns={svgns}
x="0"
y="0"
height={diameter}
width={diameter}
style={svgStyles}
>
{shapesArr.map((_, i) =>
this.genShape(
shuffledColours[i + 1],
diameter,
i,
shapeCount - 1,
generator
)
)}
</svg>
</Paper>
);
}
private hueShift(colors: Array<string>, generator: RNG) {
const amount = generator.random() * 30 - wobble / 2;
return colors.map(hex =>
Color(hex)
.rotate(amount)
.hex()
);
}
private genShape(
colour: string,
diameter: number,
i: number,
total: number,
generator: RNG
) {
const center = diameter / 2;
const firstRot = generator.random();
const angle = Math.PI * 2 * firstRot;
const velocity =
diameter / total * generator.random() + i * diameter / total;
const tx = Math.cos(angle) * velocity;
const ty = Math.sin(angle) * velocity;
const translate = `translate(${tx} ${ty})`;
// Third random is a shape rotation on top of all of that.
const secondRot = generator.random();
const rot = firstRot * 360 + secondRot * 180;
const rotate = `rotate(${rot.toFixed(1)} ${center} ${center})`;
const transform = `${translate} ${rotate}`;
return (
<rect
key={i}
x="0"
y="0"
rx="0"
ry="0"
height={diameter}
width={diameter}
transform={transform}
fill={colour}
/>
);
}
private colorsForIcon(arr: Array<string>) {
if (isColors(arr)) {
return arr;
}
return defaultColors;
}
private shuffleArray<T>(array: Array<T>, generator: RNG) {
let currentIndex = array.length;
const newArray = [...array];
// While there remain elements to shuffle...
while (currentIndex > 0) {
// Pick a remaining element...
const randomIndex = generator.next() % currentIndex;
currentIndex -= 1;
// And swap it with the current element.
const temporaryValue = newArray[currentIndex];
newArray[currentIndex] = newArray[randomIndex];
newArray[randomIndex] = temporaryValue;
}
return newArray;
}
}

View File

@ -0,0 +1,25 @@
import React from 'react';
const styles = {
borderRadius: '50px',
display: 'inline-block',
margin: 0,
overflow: 'hidden',
padding: 0,
};
// @ts-ignore
export const Paper = ({ children, color, diameter, style: styleOverrides }) => (
<div
className="paper"
style={{
...styles,
backgroundColor: color,
height: diameter,
width: diameter,
...(styleOverrides || {}),
}}
>
{children}
</div>
);

View File

@ -0,0 +1,21 @@
export class RNG {
private _seed: number;
constructor(seed: number) {
this._seed = seed % 2147483647;
if (this._seed <= 0) {
this._seed += 2147483646;
}
}
public next() {
return (this._seed = (this._seed * 16807) % 2147483647);
}
public nextFloat() {
return (this.next() - 1) / 2147483646;
}
public random() {
return this.nextFloat();
}
}

View File

@ -0,0 +1,2 @@
import { JazzIcon } from './JazzIcon';
export { JazzIcon };

View File

@ -335,6 +335,13 @@ export class MainHeader extends React.Component<Props, any> {
trigger('showSeedDialog'); trigger('showSeedDialog');
}, },
}, },
{
id: 'showQRCode',
name: i18n('showQRCode'),
onClick: () => {
trigger('showQRDialog');
},
},
]; ];
const passItem = (type: string) => ({ const passItem = (type: string) => ({

View File

@ -10,23 +10,38 @@ interface Props {
profileName?: string; profileName?: string;
i18n: LocalizerType; i18n: LocalizerType;
module?: string; module?: string;
boldProfileName?: Boolean;
compact?: Boolean;
} }
export class ContactName extends React.Component<Props> { export class ContactName extends React.Component<Props> {
public render() { public render() {
const { phoneNumber, name, profileName, i18n, module } = this.props; const {
phoneNumber,
name,
profileName,
i18n,
module,
boldProfileName,
compact,
} = this.props;
const prefix = module ? module : 'module-contact-name'; const prefix = module ? module : 'module-contact-name';
const title = name ? name : phoneNumber; const title = name ? name : phoneNumber;
const shouldShowProfile = Boolean(profileName && !name); const shouldShowProfile = Boolean(profileName && !name);
const styles = (boldProfileName
? {
fontWeight: 'bold',
}
: {}) as React.CSSProperties;
const profileElement = shouldShowProfile ? ( const profileElement = shouldShowProfile ? (
<span className={`${prefix}__profile-name`}> <span style={styles} className={`${prefix}__profile-name`}>
<Emojify text={profileName || ''} i18n={i18n} /> <Emojify text={profileName || ''} i18n={i18n} />
</span> </span>
) : null; ) : null;
return ( return (
<span className={prefix} dir="auto"> <span className={classNames(prefix, compact && 'compact')} dir="auto">
{profileElement} {profileElement}
{shouldShowProfile ? ' ' : null} {shouldShowProfile ? ' ' : null}
<span <span

View File

@ -26,8 +26,10 @@ interface Props {
isVerified: boolean; isVerified: boolean;
isMe: boolean; isMe: boolean;
isClosable?: boolean;
isGroup: boolean; isGroup: boolean;
isArchived: boolean; isArchived: boolean;
isPublic: boolean;
expirationSettingName?: string; expirationSettingName?: string;
showBackButton: boolean; showBackButton: boolean;
@ -96,7 +98,14 @@ export class ConversationHeader extends React.Component<Props> {
} }
public renderTitle() { public renderTitle() {
const { phoneNumber, i18n, profileName, isKeysPending, isMe } = this.props; const {
phoneNumber,
i18n,
profileName,
isKeysPending,
isMe,
name,
} = this.props;
if (isMe) { if (isMe) {
return ( return (
@ -111,6 +120,7 @@ export class ConversationHeader extends React.Component<Props> {
<ContactName <ContactName
phoneNumber={phoneNumber} phoneNumber={phoneNumber}
profileName={profileName} profileName={profileName}
name={name}
i18n={i18n} i18n={i18n}
/> />
{isKeysPending ? '(pending)' : null} {isKeysPending ? '(pending)' : null}
@ -191,84 +201,29 @@ export class ConversationHeader extends React.Component<Props> {
public renderMenu(triggerId: string) { public renderMenu(triggerId: string) {
const { const {
i18n, i18n,
isBlocked,
isMe, isMe,
isGroup, isClosable,
isArchived, isPublic,
onDeleteMessages, onDeleteMessages,
onDeleteContact, onDeleteContact,
onResetSession,
onSetDisappearingMessages,
// onShowAllMedia,
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
onMoveToInbox,
timerOptions,
onBlockUser,
onUnblockUser,
hasNickname,
onClearNickname,
onChangeNickname,
onCopyPublicKey, onCopyPublicKey,
} = this.props; } = this.props;
const disappearingTitle = i18n('disappearingMessages') as any;
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockUser : onBlockUser;
return ( return (
<ContextMenu id={triggerId}> <ContextMenu id={triggerId}>
<SubMenu title={disappearingTitle}> {this.renderPublicMenuItems()}
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
{/* <MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem> */}
{isGroup ? (
<MenuItem onClick={onShowGroupMembers}>
{i18n('showMembers')}
</MenuItem>
) : null}
{!isGroup && !isMe ? (
<MenuItem onClick={onShowSafetyNumber}>
{i18n('showSafetyNumber')}
</MenuItem>
) : null}
{!isGroup ? (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
) : null}
{/* Only show the block on other conversations */}
{!isMe ? (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
) : null}
{!isMe ? (
<MenuItem onClick={onChangeNickname}>
{i18n('changeNickname')}
</MenuItem>
) : null}
{!isMe && hasNickname ? (
<MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
) : null}
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem> <MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem>
{isArchived ? (
<MenuItem onClick={onMoveToInbox}>
{i18n('moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem> <MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{!isMe ? ( {!isMe && isClosable ? (
<MenuItem onClick={onDeleteContact}>{i18n('deleteContact')}</MenuItem> !isPublic ? (
<MenuItem onClick={onDeleteContact}>
{i18n('deleteContact')}
</MenuItem>
) : (
<MenuItem onClick={onDeleteContact}>
{i18n('deletePublicChannel')}
</MenuItem>
)
) : null} ) : null}
</ContextMenu> </ContextMenu>
); );
@ -293,4 +248,95 @@ export class ConversationHeader extends React.Component<Props> {
</div> </div>
); );
} }
private renderPublicMenuItems() {
const {
i18n,
isBlocked,
isMe,
isGroup,
isArchived,
isPublic,
onResetSession,
onSetDisappearingMessages,
// onShowAllMedia,
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
onMoveToInbox,
timerOptions,
onBlockUser,
onUnblockUser,
hasNickname,
onClearNickname,
onChangeNickname,
} = this.props;
if (isPublic) {
return null;
}
const disappearingTitle = i18n('disappearingMessages') as any;
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockUser : onBlockUser;
const disappearingMessagesMenuItem = (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
);
const showMembersMenuItem = isGroup && (
<MenuItem onClick={onShowGroupMembers}>{i18n('showMembers')}</MenuItem>
);
const showSafetyNumberMenuItem = !isGroup &&
!isMe && (
<MenuItem onClick={onShowSafetyNumber}>
{i18n('showSafetyNumber')}
</MenuItem>
);
const resetSessionMenuItem = !isGroup && (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
);
const blockHandlerMenuItem = !isMe && (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
);
const changeNicknameMenuItem = !isMe && (
<MenuItem onClick={onChangeNickname}>{i18n('changeNickname')}</MenuItem>
);
const clearNicknameMenuItem = !isMe &&
hasNickname && (
<MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
);
const archiveConversationMenuItem = isArchived ? (
<MenuItem onClick={onMoveToInbox}>
{i18n('moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
);
return (
<React.Fragment>
{/* <MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem> */}
{disappearingMessagesMenuItem}
{showMembersMenuItem}
{showSafetyNumberMenuItem}
{resetSessionMenuItem}
{blockHandlerMenuItem}
{changeNicknameMenuItem}
{clearNicknameMenuItem}
{archiveConversationMenuItem}
</React.Fragment>
);
}
} }

View File

@ -5,6 +5,7 @@
| Name | Values | | Name | Values |
| -------------------- | -------------------------------------------- | | -------------------- | -------------------------------------------- |
| text | string | | text | string |
| timestamp | number |
| direction | 'outgoing' \| 'incoming | | direction | 'outgoing' \| 'incoming |
| status | 'sending' \| 'sent' \| 'read' \| 'delivered' | | status | 'sending' \| 'sent' \| 'read' \| 'delivered' |
| friendStatus | 'pending' \| 'accepted' \| 'declined' | | friendStatus | 'pending' \| 'accepted' \| 'declined' |
@ -21,6 +22,7 @@
<li> <li>
<FriendRequest <FriendRequest
text="This is my friend request message!" text="This is my friend request message!"
timestamp={1567994022804}
direction="outgoing" direction="outgoing"
status="sending" status="sending"
friendStatus="pending" friendStatus="pending"

View File

@ -3,6 +3,7 @@ import classNames from 'classnames';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { MessageBody } from './MessageBody'; import { MessageBody } from './MessageBody';
import { Timestamp } from './Timestamp';
interface Props { interface Props {
text: string; text: string;
@ -11,6 +12,7 @@ interface Props {
friendStatus: 'pending' | 'accepted' | 'declined' | 'expired'; friendStatus: 'pending' | 'accepted' | 'declined' | 'expired';
i18n: LocalizerType; i18n: LocalizerType;
isBlocked: boolean; isBlocked: boolean;
timestamp: number;
onAccept: () => void; onAccept: () => void;
onDecline: () => void; onDecline: () => void;
onDeleteConversation: () => void; onDeleteConversation: () => void;
@ -142,13 +144,23 @@ export class FriendRequest extends React.Component<Props> {
// Renders 'sending', 'read' icons // Renders 'sending', 'read' icons
public renderStatusIndicator() { public renderStatusIndicator() {
const { direction, status } = this.props; const { direction, status, i18n, text, timestamp } = this.props;
if (direction !== 'outgoing' || status === 'error') { if (status === 'error') {
return null; return null;
} }
const withImageNoCaption = Boolean(!text);
return ( return (
<div className="module-message__metadata"> <div className="module-message__metadata">
<Timestamp
i18n={i18n}
timestamp={timestamp}
extended={true}
direction={direction}
withImageNoCaption={withImageNoCaption}
module="module-message__metadata__date"
/>
<span className="module-message__metadata__spacer" /> <span className="module-message__metadata__spacer" />
<div <div
className={classNames( className={classNames(

View File

@ -9,6 +9,7 @@ const linkify = LinkifyIt();
interface Props { interface Props {
text: string; text: string;
isRss?: boolean;
/** Allows you to customize now non-links are rendered. Simplest is just a <span>. */ /** Allows you to customize now non-links are rendered. Simplest is just a <span>. */
renderNonLink?: RenderTextCallbackType; renderNonLink?: RenderTextCallbackType;
} }
@ -22,12 +23,32 @@ export class Linkify extends React.Component<Props> {
}; };
public render() { public render() {
const { text, renderNonLink } = this.props; const { text, renderNonLink, isRss } = this.props;
const matchData = linkify.match(text) || [];
const results: Array<any> = []; const results: Array<any> = [];
let last = 0;
let count = 1; 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, // We have to do this, because renderNonLink is not required in our Props object,
// but it is always provided via defaultProps. // but it is always provided via defaultProps.
if (!renderNonLink) { if (!renderNonLink) {

View File

@ -48,6 +48,8 @@ interface LinkPreviewType {
export interface Props { export interface Props {
disableMenu?: boolean; disableMenu?: boolean;
isModerator?: boolean;
isDeletable: boolean;
text?: string; text?: string;
textPending?: boolean; textPending?: boolean;
id?: string; id?: string;
@ -86,6 +88,8 @@ export interface Props {
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
isP2p?: boolean; isP2p?: boolean;
isPublic?: boolean;
isRss?: boolean;
onClickAttachment?: (attachment: AttachmentType) => void; onClickAttachment?: (attachment: AttachmentType) => void;
onClickLinkPreview?: (url: string) => void; onClickLinkPreview?: (url: string) => void;
@ -94,6 +98,7 @@ export interface Props {
onRetrySend?: () => void; onRetrySend?: () => void;
onDownload?: (isDangerous: boolean) => void; onDownload?: (isDangerous: boolean) => void;
onDelete?: () => void; onDelete?: () => void;
onCopyPubKey?: () => void;
onShowDetail: () => void; onShowDetail: () => void;
} }
@ -191,6 +196,34 @@ export class Message extends React.PureComponent<Props, State> {
}); });
} }
public renderMetadataBadges() {
const { direction, isP2p, isPublic, isModerator } = this.props;
const badges = [isPublic && 'Public', isP2p && 'P2p', isModerator && 'Mod'];
return badges
.map(badgeText => {
if (typeof badgeText !== 'string') {
return null;
}
return (
<span
className={classNames(
'module-message__metadata__badge',
`module-message__metadata__badge--${direction}`,
`module-message__metadata__badge--${badgeText.toLowerCase()}`,
`module-message__metadata__badge--${badgeText.toLowerCase()}--${direction}`
)}
key={badgeText}
>
&nbsp;&nbsp;{badgeText}
</span>
);
})
.filter(i => !!i);
}
public renderMetadata() { public renderMetadata() {
const { const {
collapseMetadata, collapseMetadata,
@ -202,7 +235,6 @@ export class Message extends React.PureComponent<Props, State> {
text, text,
textPending, textPending,
timestamp, timestamp,
isP2p,
} = this.props; } = this.props;
if (collapseMetadata) { if (collapseMetadata) {
@ -244,16 +276,7 @@ export class Message extends React.PureComponent<Props, State> {
module="module-message__metadata__date" module="module-message__metadata__date"
/> />
)} )}
{isP2p ? ( {this.renderMetadataBadges()}
<span
className={classNames(
'module-message__metadata__p2p',
`module-message__metadata__p2p--${direction}`
)}
>
&nbsp;&nbsp;P2P
</span>
) : null}
{expirationLength && expirationTimestamp ? ( {expirationLength && expirationTimestamp ? (
<ExpireTimer <ExpireTimer
direction={direction} direction={direction}
@ -299,14 +322,23 @@ export class Message extends React.PureComponent<Props, State> {
return null; return null;
} }
const shortenedPubkey = `(...${authorPhoneNumber.substring(
authorPhoneNumber.length - 6
)})`;
const displayedPubkey = authorProfileName
? shortenedPubkey
: authorPhoneNumber;
return ( return (
<div className="module-message__author"> <div className="module-message__author">
<ContactName <ContactName
phoneNumber={authorPhoneNumber} phoneNumber={displayedPubkey}
name={authorName} name={authorName}
profileName={authorProfileName} profileName={authorProfileName}
module="module-message__author" module="module-message__author"
i18n={i18n} i18n={i18n}
boldProfileName={true}
/> />
</div> </div>
); );
@ -564,6 +596,14 @@ export class Message extends React.PureComponent<Props, State> {
const quoteColor = const quoteColor =
direction === 'incoming' ? authorColor : quote.authorColor; direction === 'incoming' ? authorColor : quote.authorColor;
const shortenedPubkey = `(...${quote.authorPhoneNumber.substring(
quote.authorPhoneNumber.length - 6
)})`;
const displayedPubkey = quote.authorProfileName
? shortenedPubkey
: quote.authorPhoneNumber;
return ( return (
<Quote <Quote
i18n={i18n} i18n={i18n}
@ -571,7 +611,7 @@ export class Message extends React.PureComponent<Props, State> {
text={quote.text} text={quote.text}
attachment={quote.attachment} attachment={quote.attachment}
isIncoming={direction === 'incoming'} isIncoming={direction === 'incoming'}
authorPhoneNumber={quote.authorPhoneNumber} authorPhoneNumber={displayedPubkey}
authorProfileName={quote.authorProfileName} authorProfileName={quote.authorProfileName}
authorName={quote.authorName} authorName={quote.authorName}
authorColor={quoteColor} authorColor={quoteColor}
@ -637,6 +677,7 @@ export class Message extends React.PureComponent<Props, State> {
authorPhoneNumber, authorPhoneNumber,
authorProfileName, authorProfileName,
collapseMetadata, collapseMetadata,
isModerator,
authorColor, authorColor,
conversationType, conversationType,
direction, direction,
@ -663,12 +704,17 @@ export class Message extends React.PureComponent<Props, State> {
profileName={authorProfileName} profileName={authorProfileName}
size={36} size={36}
/> />
{isModerator && (
<div className="module-avatar__icon--crown-wrapper">
<div className="module-avatar__icon--crown" />
</div>
)}
</div> </div>
); );
} }
public renderText() { public renderText() {
const { text, textPending, i18n, direction, status } = this.props; const { text, textPending, i18n, direction, status, isRss } = this.props;
const contents = const contents =
direction === 'incoming' && status === 'error' direction === 'incoming' && status === 'error'
@ -692,6 +738,7 @@ export class Message extends React.PureComponent<Props, State> {
> >
<MessageBody <MessageBody
text={contents || ''} text={contents || ''}
isRss={isRss}
i18n={i18n} i18n={i18n}
textPending={textPending} textPending={textPending}
/> />
@ -809,11 +856,14 @@ export class Message extends React.PureComponent<Props, State> {
onCopyText, onCopyText,
direction, direction,
status, status,
isDeletable,
onDelete, onDelete,
onDownload, onDownload,
onReply, onReply,
onRetrySend, onRetrySend,
onShowDetail, onShowDetail,
onCopyPubKey,
isPublic,
i18n, i18n,
} = this.props; } = this.props;
@ -866,14 +916,19 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('retrySend')} {i18n('retrySend')}
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem {isDeletable ? (
attributes={{ <MenuItem
className: 'module-message__context__delete-message', attributes={{
}} className: 'module-message__context__delete-message',
onClick={onDelete} }}
> onClick={onDelete}
{i18n('deleteMessage')} >
</MenuItem> {i18n('deleteMessage')}
</MenuItem>
) : null}
{isPublic ? (
<MenuItem onClick={onCopyPubKey}>{i18n('copyPublicKey')}</MenuItem>
) : null}
</ContextMenu> </ContextMenu>
); );
} }

View File

@ -9,6 +9,7 @@ import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
interface Props { interface Props {
text: string; text: string;
isRss?: boolean;
textPending?: boolean; textPending?: boolean;
/** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */ /** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */
disableJumbomoji?: boolean; disableJumbomoji?: boolean;
@ -73,6 +74,7 @@ export class MessageBody extends React.Component<Props> {
textPending, textPending,
disableJumbomoji, disableJumbomoji,
disableLinks, disableLinks,
isRss,
i18n, i18n,
} = this.props; } = this.props;
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
@ -93,6 +95,7 @@ export class MessageBody extends React.Component<Props> {
return this.addDownloading( return this.addDownloading(
<Linkify <Linkify
text={textWithPending} text={textWithPending}
isRss={isRss}
renderNonLink={({ key, text: nonLinkText }) => { renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({ return renderEmoji({
i18n, i18n,

View File

@ -56,7 +56,7 @@ export class MessageDetail extends React.Component<Props> {
public renderDeleteButton() { public renderDeleteButton() {
const { i18n, message } = this.props; const { i18n, message } = this.props;
return ( return message.isDeletable ? (
<div className="module-message-detail__delete-button-container"> <div className="module-message-detail__delete-button-container">
<button <button
onClick={message.onDelete} onClick={message.onDelete}
@ -65,7 +65,7 @@ export class MessageDetail extends React.Component<Props> {
{i18n('deleteThisMessage')} {i18n('deleteThisMessage')}
</button> </button>
</div> </div>
); ) : null;
} }
public renderContact(contact: Contact) { public renderContact(contact: Contact) {

View File

@ -304,6 +304,7 @@ export class Quote extends React.Component<Props, State> {
name={authorName} name={authorName}
profileName={authorProfileName} profileName={authorProfileName}
i18n={i18n} i18n={i18n}
compact={true}
/> />
)} )}
</div> </div>

View File

@ -40,10 +40,13 @@ export type ConversationType = {
lastMessage?: { lastMessage?: {
status: 'error' | 'sending' | 'sent' | 'delivered' | 'read'; status: 'error' | 'sending' | 'sent' | 'delivered' | 'read';
text: string; text: string;
isRss: boolean;
}; };
phoneNumber: string; phoneNumber: string;
type: 'direct' | 'group'; type: 'direct' | 'group';
isMe: boolean; isMe: boolean;
isPublic?: boolean;
isClosable?: boolean;
lastUpdated: number; lastUpdated: number;
unreadCount: number; unreadCount: number;
isSelected: boolean; isSelected: boolean;

View File

@ -12,13 +12,6 @@ const getShortFormats = (i18n: LocalizerType) => ({
d: 'ddd', d: 'ddd',
}); });
function isToday(timestamp: moment.Moment) {
const today = moment().format('ddd');
const targetDay = moment(timestamp).format('ddd');
return today === targetDay;
}
function isYear(timestamp: moment.Moment) { function isYear(timestamp: moment.Moment) {
const year = moment().format('YYYY'); const year = moment().format('YYYY');
const targetYear = moment(timestamp).format('YYYY'); const targetYear = moment(timestamp).format('YYYY');
@ -41,17 +34,7 @@ export function formatRelativeTime(
return timestamp.format(formats.y); return timestamp.format(formats.y);
} else if (diff.months() >= 1 || diff.days() > 6) { } else if (diff.months() >= 1 || diff.days() > 6) {
return timestamp.format(formats.M); return timestamp.format(formats.M);
} else if (diff.days() >= 1 || !isToday(timestamp)) {
return timestamp.format(formats.d);
} else if (diff.hours() >= 1) {
const key = extended ? 'hoursAgo' : 'hoursAgoShort';
return i18n(key, [String(diff.hours())]);
} else if (diff.minutes() >= 1) {
const key = extended ? 'minutesAgo' : 'minutesAgoShort';
return i18n(key, [String(diff.minutes())]);
} }
return i18n('justNow'); return timestamp.format(formats.d);
} }

View File

@ -136,7 +136,15 @@
// 'as' is nicer than angle brackets. // 'as' is nicer than angle brackets.
"prefer-type-cast": false, "prefer-type-cast": false,
// We use || and && shortcutting because we're javascript programmers // 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"] "rulesDirectory": ["node_modules/tslint-microsoft-contrib"]
} }

113
yarn.lock
View File

@ -89,6 +89,25 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"
integrity sha512-x15/Io+JdzrkM9gnX6SWUs/EmqQzd65TD9tcZIAQ1VIdb93XErNuYmB7Yho8JUCE189ipUSESsWvGvYXRRIvYA== integrity sha512-x15/Io+JdzrkM9gnX6SWUs/EmqQzd65TD9tcZIAQ1VIdb93XErNuYmB7Yho8JUCE189ipUSESsWvGvYXRRIvYA==
"@types/color-convert@*":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-1.9.0.tgz#bfa8203e41e7c65471e9841d7e306a7cd8b5172d"
integrity sha512-OKGEfULrvSL2VRbkl/gnjjgbbF7ycIlpSsX7Nkab4MOWi5XxmgBYvuiQ7lcCFY5cPDz7MUNaKgxte2VRmtr4Fg==
dependencies:
"@types/color-name" "*"
"@types/color-name@*":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/color@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/color/-/color-3.0.0.tgz#40f8a6bf2fd86e969876b339a837d8ff1b0a6e30"
integrity sha512-5qqtNia+m2I0/85+pd2YzAXaTyKO8j+svirO5aN+XaQJ5+eZ8nx0jPtEWZLxCi50xwYsX10xUHetFzfb1WEs4Q==
dependencies:
"@types/color-convert" "*"
"@types/config@0.0.34": "@types/config@0.0.34":
version "0.0.34" version "0.0.34"
resolved "https://registry.yarnpkg.com/@types/config/-/config-0.0.34.tgz#123f91bdb5afdd702294b9de9ca04d9ea11137b0" resolved "https://registry.yarnpkg.com/@types/config/-/config-0.0.34.tgz#123f91bdb5afdd702294b9de9ca04d9ea11137b0"
@ -726,6 +745,11 @@ ast-types@^0.7.2:
version "0.7.8" version "0.7.8"
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.7.8.tgz#902d2e0d60d071bdcd46dc115e1809ed11c138a9" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.7.8.tgz#902d2e0d60d071bdcd46dc115e1809ed11c138a9"
astral-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
async-each@^1.0.0: async-each@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
@ -1632,6 +1656,14 @@ cli-spinners@^1.1.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-1.3.0.tgz#6ba8b357395f07b7981c1acc2614485ee8c02a2d" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-1.3.0.tgz#6ba8b357395f07b7981c1acc2614485ee8c02a2d"
cli-truncate@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.0.0.tgz#68ff6aaa53b203b52ad89b8b1a80f1f61ad1e1d5"
integrity sha512-C4hp+8GCIFVsUUiXcw+ce+7wexVWImw8rQrgMBFsqerx9LvvcGlwm6sMjQYAEmV/Xb87xc1b5Ttx505MSpZVqg==
dependencies:
slice-ansi "^2.1.0"
string-width "^4.1.0"
cli-width@^2.0.0: cli-width@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a"
@ -1720,11 +1752,18 @@ color-convert@^1.9.0:
dependencies: dependencies:
color-name "^1.1.1" color-name "^1.1.1"
color-convert@^1.9.1:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
dependencies:
color-name "1.1.3"
color-convert@~0.5.0: color-convert@~0.5.0:
version "0.5.3" version "0.5.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
color-name@^1.0.0, color-name@^1.1.1: color-name@1.1.3, color-name@^1.0.0, color-name@^1.1.1:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
@ -1735,6 +1774,14 @@ color-string@^0.3.0:
dependencies: dependencies:
color-name "^1.0.0" color-name "^1.0.0"
color-string@^1.5.2:
version "1.5.3"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color@^0.11.0: color@^0.11.0:
version "0.11.4" version "0.11.4"
resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764"
@ -1744,6 +1791,14 @@ color@^0.11.0:
color-convert "^1.3.0" color-convert "^1.3.0"
color-string "^0.3.0" color-string "^0.3.0"
color@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10"
integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==
dependencies:
color-convert "^1.9.1"
color-string "^1.5.2"
colormin@^1.0.5: colormin@^1.0.5:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133"
@ -2649,11 +2704,12 @@ electron-chromedriver@~3.0.0:
electron-download "^4.1.0" electron-download "^4.1.0"
extract-zip "^1.6.5" extract-zip "^1.6.5"
electron-context-menu@^0.11.0: electron-context-menu@^0.15.0:
version "0.11.0" version "0.15.0"
resolved "https://registry.yarnpkg.com/electron-context-menu/-/electron-context-menu-0.11.0.tgz#3ecefb0231151add474c9b0df2fb66fde3ad5731" resolved "https://registry.yarnpkg.com/electron-context-menu/-/electron-context-menu-0.15.0.tgz#0a5abe8e73aca9cd9b891ce62830c984ecedff51"
integrity sha512-sgDIGqjgazUQ5fbfz0ObRkmODAsw00eylQprp5q4jyuL6dskd27yslhoJTrjLbFGErfVVYzRXPW2rQPJxARKmg== integrity sha512-XLdtbX90NPkWycG3IzwtCmfX4ggu+lofNOW1nVRStb+ScFs49WTourW1k77Z4DTyThR3gUHg3UPXVBMbW1gNsg==
dependencies: dependencies:
cli-truncate "^2.0.0"
electron-dl "^1.2.0" electron-dl "^1.2.0"
electron-is-dev "^1.0.1" electron-is-dev "^1.0.1"
@ -2790,6 +2846,11 @@ emoji-regex@^7.0.1:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
emojis-list@^2.0.0: emojis-list@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
@ -4461,11 +4522,6 @@ icss-utils@^2.1.0:
dependencies: dependencies:
postcss "^6.0.1" postcss "^6.0.1"
identicon.js@2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/identicon.js/-/identicon.js-2.3.3.tgz#c505b8d60ecc6ea13bbd991a33964c44c1ad60a1"
integrity sha512-/qgOkXKZ7YbeCYbawJ9uQQ3XJ3uBg9VDpvHjabCAPp6aRMhjLaFAxG90+1TxzrhKaj6AYpVGrx6UXQfQA41UEA==
ieee754@^1.1.4: ieee754@^1.1.4:
version "1.1.8" version "1.1.8"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
@ -4650,6 +4706,11 @@ is-arrayish@^0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-binary-path@^1.0.0: is-binary-path@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
@ -4768,6 +4829,11 @@ is-fullwidth-code-point@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-function@^1.0.1, is-function@~1.0.0: is-function@^1.0.1, is-function@~1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5"
@ -8616,6 +8682,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.1, signal-exit@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
dependencies:
is-arrayish "^0.3.1"
single-line-log@^1.1.2: single-line-log@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/single-line-log/-/single-line-log-1.1.2.tgz#c2f83f273a3e1a16edb0995661da0ed5ef033364" resolved "https://registry.yarnpkg.com/single-line-log/-/single-line-log-1.1.2.tgz#c2f83f273a3e1a16edb0995661da0ed5ef033364"
@ -8645,6 +8718,15 @@ slice-ansi@1.0.0:
dependencies: dependencies:
is-fullwidth-code-point "^2.0.0" is-fullwidth-code-point "^2.0.0"
slice-ansi@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==
dependencies:
ansi-styles "^3.2.0"
astral-regex "^1.0.0"
is-fullwidth-code-point "^2.0.0"
slide@^1.1.5: slide@^1.1.5:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
@ -9041,6 +9123,15 @@ string-width@^3.0.0:
is-fullwidth-code-point "^2.0.0" is-fullwidth-code-point "^2.0.0"
strip-ansi "^5.1.0" strip-ansi "^5.1.0"
string-width@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.1.0.tgz#ba846d1daa97c3c596155308063e075ed1c99aff"
integrity sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^5.2.0"
string_decoder@^1.0.0: string_decoder@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@ -9091,7 +9182,7 @@ strip-ansi@^4.0.0:
dependencies: dependencies:
ansi-regex "^3.0.0" ansi-regex "^3.0.0"
strip-ansi@^5.1.0: strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==