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
curly: 'error',
curly: ['error', 'all'],
'brace-style': ['error', '1tbs'],
// prevents us from accidentally checking in exclusive tests (`.only`):
'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": {
"message": "Delete"
},
"deletePublicWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove this message for everyone in this channel."
},
"deleteWarning": {
"message":
"Are you sure? Clicking 'delete' will permanently remove this message from this device only."
@ -1037,11 +1041,27 @@
"message": "Delete messages",
"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": {
"message": "Permanently delete this conversation?",
"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."
},
"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": {
"message": "Delete contact",
"description":
@ -1916,6 +1936,10 @@
"description":
"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": {
"message":

View File

@ -1,8 +1,6 @@
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const Identicon = require('identicon.js');
const sha224 = require('js-sha512').sha512_224;
const { app } = require('electron').remote;
@ -13,12 +11,6 @@ mkdirp.sync(PATH);
const hasImage = pubKey => fs.existsSync(getImagePath(pubKey));
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 => {
if (hasImage(pubKey)) {
@ -39,25 +31,14 @@ const removeImagesNotInArray = pubKeyArray => {
.forEach(i => removeImage(i));
};
const generateImage = pubKey => {
const writePNGImage = (base64String, pubKey) => {
const imagePath = getImagePath(pubKey);
/*
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');
fs.writeFileSync(imagePath, base64String, 'base64');
return imagePath;
};
module.exports = {
generateImage,
getOrCreateImagePath,
writePNGImage,
getImagePath,
hasImage,
removeImage,

View File

@ -5,6 +5,7 @@ const sql = require('@journeyapps/sqlcipher');
const { app, dialog, clipboard } = require('electron');
const { redactAll } = require('../js/modules/privacy');
const { remove: removeUserConfig } = require('./user_config');
const config = require('./config');
const pify = require('pify');
const uuidv4 = require('uuid/v4');
@ -100,10 +101,15 @@ module.exports = {
saveConversation,
saveConversations,
getConversationById,
savePublicServerToken,
getPublicServerTokenByServerUrl,
updateConversation,
removeConversation,
getAllConversations,
getConversationsWithFriendStatus,
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
@ -124,6 +130,7 @@ module.exports = {
removeMessage,
getUnreadByConversation,
getMessageBySender,
getMessageByServerId,
getMessageById,
getAllMessages,
getAllMessageIds,
@ -780,7 +787,140 @@ async function updateSchema(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) {
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(
`INSERT INTO loki_schema (
version
@ -816,7 +952,7 @@ async function updateToLokiSchemaVersion2(currentVersion, instance) {
async function updateLokiSchema(instance) {
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) {
await createLokiSchemaTable(instance);
@ -842,9 +978,9 @@ async function updateLokiSchema(instance) {
async function getLokiSchemaVersion(instance) {
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 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) {
const row = await db.get(
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`,
@ -1676,6 +1844,41 @@ async function getAllPrivateConversations() {
return map(rows, row => jsonToObject(row.json));
}
async function getAllRssFeedConversations() {
const rows = await db.all(
`SELECT json FROM conversations WHERE
type = 'group' AND
id LIKE 'rss://%'
ORDER BY id ASC;`
);
return map(rows, row => jsonToObject(row.json));
}
async function getAllPublicConversations() {
const rows = await db.all(
`SELECT json FROM conversations WHERE
type = 'group' AND
id LIKE 'publicChat:%'
ORDER BY id ASC;`
);
return map(rows, row => jsonToObject(row.json));
}
async function getPublicConversationsByServer(server) {
const rows = await db.all(
`SELECT * FROM conversations WHERE
server = $server
ORDER BY id ASC;`,
{
$server: server,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function getAllGroupsInvolvingId(id) {
const rows = await db.all(
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
@ -1783,6 +1986,7 @@ async function saveMessage(data, { forceSave } = {}) {
hasFileAttachments,
hasVisualMediaAttachments,
id,
serverId,
// eslint-disable-next-line camelcase
received_at,
schemaVersion,
@ -1801,6 +2005,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id: id,
$json: objectToJSON(data),
$serverId: serverId,
$body: body,
$conversationId: conversationId,
$expirationStartTimestamp: expirationStartTimestamp,
@ -1823,6 +2028,7 @@ async function saveMessage(data, { forceSave } = {}) {
await db.run(
`UPDATE messages SET
json = $json,
serverId = $serverId,
body = $body,
conversationId = $conversationId,
expirationStartTimestamp = $expirationStartTimestamp,
@ -1857,6 +2063,7 @@ async function saveMessage(data, { forceSave } = {}) {
id,
json,
serverId,
body,
conversationId,
expirationStartTimestamp,
@ -1877,6 +2084,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id,
$json,
$serverId,
$body,
$conversationId,
$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) {
const row = await db.get('SELECT * FROM messages WHERE id = $id;', {
$id: id,

View File

@ -315,6 +315,13 @@
</div>
</div>
</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'>
<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>
<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/password_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/identicon_svg_view.js'></script>
<script type='text/javascript' src='js/views/install_view.js'></script>

View File

@ -4,14 +4,22 @@
"cdnUrl": "random.snode",
"contentProxyUrl": "",
"localServerPort": "8081",
"defaultPoWDifficulty": "100",
"defaultPoWDifficulty": "1",
"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"
}
],
"disableAutoUpdate": false,
"disableAutoUpdate": true,
"updatesUrl": "https://updates2.signal.org/desktop",
"updatesPublicKey":
"fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401",
@ -22,5 +30,6 @@
"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",
"import": false,
"serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx"
"serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx",
"defaultPublicChatServer": "https://chat.lokinet.org/"
}

View File

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

View File

@ -1,4 +1,11 @@
{
"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',
'clock.svg',
'close-circle.svg',
'crown.svg',
'delete.svg',
'dots-horizontal.svg',
'double-check.svg',
@ -206,14 +207,41 @@
window.log.info('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;
const initAPIs = () => {
const initAPIs = async () => {
if (initialisedAPI) {
return;
}
const ourKey = textsecure.storage.user.getNumber();
window.feeds = [];
window.lokiMessageAPI = new window.LokiMessageAPI(ourKey);
window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey);
window.lokiP2pAPI = new window.LokiP2pAPI(ourKey);
window.lokiP2pAPI.on('pingContact', pubKey => {
const isPing = true;
@ -255,11 +283,6 @@
}
first = false;
if (Whisper.Registration.isDone()) {
startLocalLokiServer();
initAPIs();
}
const currentPoWDifficulty = storage.get('PoWDifficulty', null);
if (!currentPoWDifficulty) {
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', () => {
const { appView } = window.owsDesktopApp;
if (appView) {
@ -567,11 +612,9 @@
window.log.info('Cleanup: complete');
window.log.info('listening for registration events');
Whisper.events.on('registration_done', () => {
Whisper.events.on('registration_done', async () => {
window.log.info('handling registration event');
startLocalLokiServer();
// listeners
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
// window.Signal.RefreshSenderCertificate.initialize({
@ -581,7 +624,6 @@
// logger: window.log,
// });
initAPIs();
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 () => {
if (appView) {
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', () => {
if (appView && appView.inboxView) {
appView.inboxView.trigger('password-updated');
@ -861,6 +922,9 @@
Whisper.Notifications.disable(); // avoid notification flood until empty
// initialize the socket and start listening for messages
startLocalLokiServer();
await initAPIs();
await initSpecialConversations();
messageReceiver = new textsecure.MessageReceiver(
USERNAME,
PASSWORD,
@ -1299,7 +1363,21 @@
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);
if (isDuplicate) {
window.log.warn('Received duplicate message', message.idForLogging());
@ -1384,10 +1462,10 @@
return new Whisper.Message({
source: textsecure.storage.user.getNumber(),
sourceDevice: data.device,
sourceDevice: data.sourceDevice,
sent_at: data.timestamp,
sent_to: sentTo,
received_at: now,
received_at: data.isPublic ? data.receivedAt : now,
conversationId: data.destination,
type: 'outgoing',
sent: true,
@ -1425,6 +1503,7 @@
let messageData = {
source: data.source,
sourceDevice: data.sourceDevice,
serverId: data.serverId,
sent_at: data.timestamp,
received_at: data.receivedAt || Date.now(),
conversationId: data.source,
@ -1432,6 +1511,8 @@
type: 'incoming',
unread: 1,
isP2p: data.isP2p,
isPublic: data.isPublic,
isRss: data.isRss,
};
if (data.friendRequest) {

View File

@ -42,7 +42,9 @@
storage.addBlockedNumber(number);
// Make sure we don't add duplicates
if (blockedNumbers.getModel(number)) return;
if (blockedNumbers.getModel(number)) {
return;
}
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 */
@ -159,6 +159,10 @@
if (!conversation) {
return;
}
if (conversation.isPublic()) {
const server = conversation.getPublicSource();
lokiPublicChatAPI.unregisterChannel(server.server, server.channelId);
}
await conversation.destroyMessages();
const deviceIds = await textsecure.storage.protocol.getDeviceIds(id);
await Promise.all(

View File

@ -13,6 +13,9 @@
window.Signal = window.Signal || {};
window.Signal.LinkPreviews = window.Signal.LinkPreviews || {};
// A cache mapping url to fetched previews
const previewCache = {};
async function makeChunkedRequest(url) {
const PARALLELISM = 3;
const size = await textsecure.messaging.getProxiedSize(url);
@ -68,13 +71,36 @@
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;
try {
html = await textsecure.messaging.makeProxiedRequest(url);
} catch (error) {
if (error.code >= 300) {
return null;
throw new Error(`Failed to fetch html: ${error}`);
}
}

View File

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

View File

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

View File

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

View File

@ -31,9 +31,14 @@ const SUPPORTED_DOMAINS = [
'imgur.com',
'www.imgur.com',
'm.imgur.com',
'i.imgur.com',
'instagram.com',
'www.instagram.com',
'm.instagram.com',
'tenor.com',
'gph.is',
'giphy.com',
'media.giphy.com',
];
function isLinkInWhitelist(link) {
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) {
try {
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_IMAGE = /<meta\s+property="og:image"\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+(?:class="dynamic"\s+)?property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
function _getMetaTag(html, regularExpression) {
const match = regularExpression.exec(html);
if (match && match[1]) {
@ -96,7 +101,8 @@ function getTitleMetaTag(html) {
return _getMetaTag(html, META_TITLE);
}
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) {

View File

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

View File

@ -70,7 +70,9 @@ class LokiP2pAPI extends EventEmitter {
}
getContactP2pDetails(pubKey) {
if (!this.contactP2pDetails[pubKey]) return null;
if (!this.contactP2pDetails[pubKey]) {
return null;
}
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();
return new Promise((resolve, reject) => {
upnpClient.externalIp((err, ip) => {
if (err) reject(err);
else {
if (err) {
reject(err);
} else {
resolve(ip);
}
});
@ -89,6 +90,7 @@ class LokiSnodeAPI {
async initialiseRandomPool(seedNodes = [...window.seedNodeList]) {
const params = {
limit: 20,
active_only: true,
fields: {
public_ip: true,
storage_port: true,
@ -134,8 +136,8 @@ class LokiSnodeAPI {
await conversation.updateSwarmNodes(filteredNodes);
}
async updateLastHash(nodeUrl, lastHash, expiresAt) {
await window.Signal.Data.updateLastHash({ nodeUrl, lastHash, expiresAt });
async updateLastHash(snode, hash, expiresAt) {
await window.Signal.Data.updateLastHash({ snode, hash, expiresAt });
}
getSwarmNodesForPubKey(pubKey) {

Binary file not shown.

View File

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

View File

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

View File

@ -201,10 +201,12 @@
isVerified: this.model.isVerified(),
isKeysPending: !this.model.isFriend(),
isMe: this.model.isMe(),
isClosable: this.model.isClosable(),
isBlocked: this.model.isBlocked(),
isGroup: !this.model.isPrivate(),
isOnline: this.model.isOnline(),
isArchived: this.model.get('isArchived'),
isPublic: this.model.isPublic(),
expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length),
@ -389,7 +391,9 @@
},
onChangePlaceholder(type) {
if (!this.$messageField) return;
if (!this.$messageField) {
return;
}
let placeholder;
switch (type) {
case 'friend-request':
@ -1290,15 +1294,27 @@
},
deleteMessage(message) {
const warningMessage = this.model.isPublic()
? i18n('deletePublicWarning')
: i18n('deleteWarning');
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('deleteWarning'),
message: warningMessage,
okText: i18n('delete'),
resolve: () => {
window.Signal.Data.removeMessage(message.id, {
resolve: async () => {
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.trigger('unload');
this.model.messageCollection.remove(message.id);
this.resetPanel();
this.updateHeader();
},
@ -1486,8 +1502,12 @@
},
destroyMessages() {
const message = this.model.isPublic()
? i18n('deletePublicConversationConfirmation')
: i18n('deleteConversationConfirmation');
Whisper.events.trigger('showConfirmationDialog', {
message: i18n('deleteConversationConfirmation'),
message,
onOk: async () => {
try {
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(
friendKeys.map(async pubKey => {
if (pubKey === textsecure.storage.user.getNumber()) return;
if (pubKey === textsecure.storage.user.getNumber()) {
return;
}
try {
await sendOnlineBroadcastMessage(pubKey);
} catch (e) {

View File

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

View File

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

View File

@ -46,11 +46,17 @@ const pow = {
// Compare two Uint8Arrays, return true if arr1 is > arr2
greaterThan(arr1, arr2) {
// 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) {
if (arr1[i] > arr2[i]) return true;
if (arr1[i] < arr2[i]) return false;
if (arr1[i] > arr2[i]) {
return true;
}
if (arr1[i] < arr2[i]) {
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) {
this.name = 'TimeStampError';
@ -273,6 +283,18 @@
}
inherit(ReplayableError, TimestampError);
function PublicChatError(message) {
this.name = 'PublicChatError';
this.message = message;
Error.call(this, message);
// Maintains proper stack trace, where our error was thrown (only available on V8)
// via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
if (Error.captureStackTrace) {
Error.captureStackTrace(this);
}
}
window.textsecure.UnregisteredUserError = UnregisteredUserError;
window.textsecure.SendMessageNetworkError = SendMessageNetworkError;
window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError;
@ -292,4 +314,6 @@
window.textsecure.WrongSwarmError = WrongSwarmError;
window.textsecure.WrongDifficultyError = WrongDifficultyError;
window.textsecure.TimestampError = TimestampError;
window.textsecure.PublicChatError = PublicChatError;
window.textsecure.PublicTokenError = PublicTokenError;
})();

View File

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

View File

@ -33,7 +33,9 @@ window.textsecure.storage.impl = {
*** Override Storage Routines ***
**************************** */
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;
postMessage({ method: 'set', key, value });
},

View File

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

View File

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

View File

@ -14,13 +14,17 @@
*** Base Storage Routines ***
**************************** */
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));
},
get(key, defaultValue) {
const value = localStorage.getItem(`${key}`);
if (value === null) return defaultValue;
if (value === null) {
return defaultValue;
}
return JSON.parse(value);
},

View File

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

View File

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

View File

@ -3,7 +3,7 @@
"productName": "Loki Messenger",
"description": "Private messaging from your desktop",
"repository": "https://github.com/loki-project/loki-messenger.git",
"version": "1.0.0-beta3",
"version": "1.0.0-beta5",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
@ -14,13 +14,13 @@
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
"start": "electron .",
"start-multi": "NODE_APP_INSTANCE=1 electron .",
"start-prod": "LOKI_DEV=1 electron .",
"start-prod-multi": "LOKI_DEV=1 NODE_APP_INSTANCE=1 electron .",
"start-prod": "NODE_ENV=production NODE_APP_INSTANCE=devprod LOKI_DEV=1 electron .",
"start-prod-multi": "NODE_ENV=production NODE_APP_INSTANCE=devprod1 LOKI_DEV=1 electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",
"build": "build --config.extraMetadata.environment=$SIGNAL_ENV",
"build-release": "SIGNAL_ENV=production && npm run build -- --config.directories.output=release",
"build-release": "export SIGNAL_ENV=production && npm run build -- --config.directories.output=release",
"sign-release": "node ts/updater/generateSignature.js",
"build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
@ -60,8 +60,9 @@
"buffer-crc32": "0.2.13",
"bunyan": "1.8.12",
"classnames": "2.2.5",
"color": "^3.1.2",
"config": "1.28.1",
"electron-context-menu": "^0.11.0",
"electron-context-menu": "^0.15.0",
"electron-editor-context-menu": "1.1.1",
"electron-is-dev": "0.3.0",
"emoji-datasource": "4.0.0",
@ -76,7 +77,6 @@
"google-libphonenumber": "3.2.2",
"got": "8.2.0",
"he": "1.2.0",
"identicon.js": "2.3.3",
"intl-tel-input": "12.1.15",
"jquery": "3.3.1",
"js-sha512": "0.8.0",
@ -122,6 +122,7 @@
"devDependencies": {
"@types/chai": "4.1.2",
"@types/classnames": "2.2.3",
"@types/color": "^3.0.0",
"@types/config": "0.0.34",
"@types/filesize": "3.6.0",
"@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.LokiPublicChatAPI = require('./js/modules/loki_public_chat_api');
window.LokiRssAPI = require('./js/modules/loki_rss_api');
window.LocalLokiServer = require('./libloki/modules/local_loki_server');
window.localServerPort = config.localServerPort;
@ -398,14 +402,25 @@ window.Signal.Logs = require('./js/modules/logs');
// Add right-click listener for selected text and urls
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({
showInspectElement: false,
shouldShowMenu: (event, params) =>
Boolean(
!params.isEditable &&
params.mediaType === 'none' &&
(params.linkURL || params.selectionText)
),
shouldShowMenu: (event, params) => {
const isRegular =
params.mediaType === 'none' && (params.linkURL || params.selectionText);
return Boolean(!params.isEditable && (isQR(params) || isRegular));
},
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

View File

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

View File

@ -867,3 +867,17 @@ $loading-height: 16px;
.inbox {
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;
}
.module-contact-name.compact {
display: block;
}
// Module: Message
.module-message {
@ -510,23 +514,25 @@
}
.module-message__metadata__date,
.module-message__metadata__p2p {
.module-message__metadata__badge {
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
color: $color-gray-60;
text-transform: uppercase;
}
.module-message__metadata__date--incoming,
.module-message__metadata__p2p--incoming {
color: $color-white-08;
}
.module-message__metadata__date--with-image-no-caption {
color: $color-white;
.module-message__metadata__badge {
font-weight: bold;
}
.module-message__metadata__p2p {
font-weight: bold;
.module-message__metadata__date--incoming,
.module-message__metadata__badge--incoming {
color: $color-white-08;
}
.module-message__metadata__date--with-image-no-caption {
color: $color-white;
}
.module-message__metadata__spacer {
@ -2051,6 +2057,25 @@
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 {
@include color-svg('../images/profile-group.svg', $color-white);
}
@ -3251,6 +3276,7 @@
.module-left-pane__list {
flex-grow: 1;
flex-shrink: 1;
overflow-y: auto;
}
.module-left-pane__virtual-list {

View File

@ -1325,6 +1325,10 @@ body.dark-theme {
background-color: $color-gray-05;
}
.module-avatar__icon--crown-wrapper {
background-color: $color-gray-75;
}
.module-avatar--no-image {
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/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/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/last_seen_indicator_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 = `
<meta property="og:site_name" content="Imgur">
<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', () => {
const html = `
<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)
);
});
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', () => {

View File

@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import { JazzIcon } from './JazzIcon';
import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util';
@ -22,7 +23,7 @@ interface State {
imageBroken: boolean;
}
export class Avatar extends React.Component<Props, State> {
export class Avatar extends React.PureComponent<Props, State> {
public handleImageErrorBound: () => void;
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() {
const {
avatarPath,
@ -129,10 +146,18 @@ export class Avatar extends React.Component<Props, State> {
}
public render() {
const { avatarPath, color, size, noteToSelf } = this.props;
const {
avatarPath,
color,
size,
noteToSelf,
conversationType,
} = this.props;
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) {
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 ? this.renderImage() : this.renderNoImage()}
{hasImage ? this.renderAvatarOrIdenticon() : this.renderNoImage()}
</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) {
const borderWidth = typeof width === 'number' ? width : 3;

View File

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

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');
},
},
{
id: 'showQRCode',
name: i18n('showQRCode'),
onClick: () => {
trigger('showQRDialog');
},
},
];
const passItem = (type: string) => ({

View File

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

View File

@ -26,8 +26,10 @@ interface Props {
isVerified: boolean;
isMe: boolean;
isClosable?: boolean;
isGroup: boolean;
isArchived: boolean;
isPublic: boolean;
expirationSettingName?: string;
showBackButton: boolean;
@ -96,7 +98,14 @@ export class ConversationHeader extends React.Component<Props> {
}
public renderTitle() {
const { phoneNumber, i18n, profileName, isKeysPending, isMe } = this.props;
const {
phoneNumber,
i18n,
profileName,
isKeysPending,
isMe,
name,
} = this.props;
if (isMe) {
return (
@ -111,6 +120,7 @@ export class ConversationHeader extends React.Component<Props> {
<ContactName
phoneNumber={phoneNumber}
profileName={profileName}
name={name}
i18n={i18n}
/>
{isKeysPending ? '(pending)' : null}
@ -191,84 +201,29 @@ export class ConversationHeader extends React.Component<Props> {
public renderMenu(triggerId: string) {
const {
i18n,
isBlocked,
isMe,
isGroup,
isArchived,
isClosable,
isPublic,
onDeleteMessages,
onDeleteContact,
onResetSession,
onSetDisappearingMessages,
// onShowAllMedia,
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
onMoveToInbox,
timerOptions,
onBlockUser,
onUnblockUser,
hasNickname,
onClearNickname,
onChangeNickname,
onCopyPublicKey,
} = this.props;
const disappearingTitle = i18n('disappearingMessages') as any;
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockUser : onBlockUser;
return (
<ContextMenu id={triggerId}>
<SubMenu title={disappearingTitle}>
{(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}
{this.renderPublicMenuItems()}
<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>
{!isMe ? (
<MenuItem onClick={onDeleteContact}>{i18n('deleteContact')}</MenuItem>
{!isMe && isClosable ? (
!isPublic ? (
<MenuItem onClick={onDeleteContact}>
{i18n('deleteContact')}
</MenuItem>
) : (
<MenuItem onClick={onDeleteContact}>
{i18n('deletePublicChannel')}
</MenuItem>
)
) : null}
</ContextMenu>
);
@ -293,4 +248,95 @@ export class ConversationHeader extends React.Component<Props> {
</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 |
| -------------------- | -------------------------------------------- |
| text | string |
| timestamp | number |
| direction | 'outgoing' \| 'incoming |
| status | 'sending' \| 'sent' \| 'read' \| 'delivered' |
| friendStatus | 'pending' \| 'accepted' \| 'declined' |
@ -21,6 +22,7 @@
<li>
<FriendRequest
text="This is my friend request message!"
timestamp={1567994022804}
direction="outgoing"
status="sending"
friendStatus="pending"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,13 +12,6 @@ const getShortFormats = (i18n: LocalizerType) => ({
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) {
const year = moment().format('YYYY');
const targetYear = moment(timestamp).format('YYYY');
@ -41,17 +34,7 @@ export function formatRelativeTime(
return timestamp.format(formats.y);
} else if (diff.months() >= 1 || diff.days() > 6) {
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.
"prefer-type-cast": false,
// We use || and && shortcutting because we're javascript programmers
"strict-boolean-expressions": false
"strict-boolean-expressions": false,
"react-no-dangerous-html": [
true,
{
"file": "ts/components/conversation/Linkify.tsx",
"method": "render",
"comment": "Usage has been approved by Ryan Tharp on 2019-07-22"
}
]
},
"rulesDirectory": ["node_modules/tslint-microsoft-contrib"]
}

113
yarn.lock
View File

@ -89,6 +89,25 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"
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":
version "0.0.34"
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"
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:
version "1.0.1"
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"
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:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a"
@ -1720,11 +1752,18 @@ color-convert@^1.9.0:
dependencies:
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:
version "0.5.3"
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"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
@ -1735,6 +1774,14 @@ color-string@^0.3.0:
dependencies:
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:
version "0.11.4"
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-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:
version "1.1.2"
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"
extract-zip "^1.6.5"
electron-context-menu@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/electron-context-menu/-/electron-context-menu-0.11.0.tgz#3ecefb0231151add474c9b0df2fb66fde3ad5731"
integrity sha512-sgDIGqjgazUQ5fbfz0ObRkmODAsw00eylQprp5q4jyuL6dskd27yslhoJTrjLbFGErfVVYzRXPW2rQPJxARKmg==
electron-context-menu@^0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/electron-context-menu/-/electron-context-menu-0.15.0.tgz#0a5abe8e73aca9cd9b891ce62830c984ecedff51"
integrity sha512-XLdtbX90NPkWycG3IzwtCmfX4ggu+lofNOW1nVRStb+ScFs49WTourW1k77Z4DTyThR3gUHg3UPXVBMbW1gNsg==
dependencies:
cli-truncate "^2.0.0"
electron-dl "^1.2.0"
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"
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:
version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
@ -4461,11 +4522,6 @@ icss-utils@^2.1.0:
dependencies:
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:
version "1.1.8"
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"
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:
version "1.0.1"
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"
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:
version "1.0.1"
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"
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:
version "1.1.2"
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:
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:
version "1.1.6"
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"
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:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@ -9091,7 +9182,7 @@ strip-ansi@^4.0.0:
dependencies:
ansi-regex "^3.0.0"
strip-ansi@^5.1.0:
strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==