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:
commit
0426d85e7a
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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":
|
||||
|
|
|
@ -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,
|
||||
|
|
242
app/sql.js
242
app/sql.js
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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/"
|
||||
}
|
||||
|
|
|
@ -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/"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
{
|
||||
"storageProfile": "development",
|
||||
"openDevTools": true
|
||||
"seedNodeList": [
|
||||
{
|
||||
"ip": "storage.testnetseed1.loki.network",
|
||||
"port": "38157"
|
||||
}
|
||||
],
|
||||
"openDevTools": true,
|
||||
"defaultPublicChatServer": "https://chat-dev.lokinet.org/"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"storageProfile": "devprodProfile",
|
||||
"openDevTools": true
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"storageProfile": "devprod1Profile",
|
||||
"localServerPort": "8082",
|
||||
"openDevTools": true
|
||||
}
|
|
@ -1,16 +1 @@
|
|||
{
|
||||
"seedNodeList": [
|
||||
{
|
||||
"ip": "storage.seed1.loki.network",
|
||||
"port": "22023"
|
||||
},
|
||||
{
|
||||
"ip": "storage.seed2.loki.network",
|
||||
"port": "38157"
|
||||
},
|
||||
{
|
||||
"ip": "imaginary.stream",
|
||||
"port": "38157"
|
||||
}
|
||||
]
|
||||
}
|
||||
{}
|
||||
|
|
|
@ -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 |
107
js/background.js
107
js/background.js
|
@ -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) {
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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] };
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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.
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -148,8 +148,11 @@ class LocalLokiServer extends EventEmitter {
|
|||
ttl,
|
||||
},
|
||||
err => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
})();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -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];
|
||||
},
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
13
package.json
13
package.json
|
@ -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",
|
||||
|
|
27
preload.js
27
preload.js
|
@ -324,6 +324,10 @@ window.LokiP2pAPI = require('./js/modules/loki_p2p_api');
|
|||
|
||||
window.LokiMessageAPI = require('./js/modules/loki_message_api');
|
||||
|
||||
window.LokiPublicChatAPI = require('./js/modules/loki_public_chat_api');
|
||||
|
||||
window.LokiRssAPI = require('./js/modules/loki_rss_api');
|
||||
|
||||
window.LocalLokiServer = require('./libloki/modules/local_loki_server');
|
||||
|
||||
window.localServerPort = config.localServerPort;
|
||||
|
@ -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
|
||||
|
|
|
@ -383,6 +383,7 @@
|
|||
border: 1px solid $color-loki-green;
|
||||
color: white;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
&:hover,
|
||||
&:disabled {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 & 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 Second Third"><meta property="og:title" content="Fourth Fifth 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', () => {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
import { JazzIcon } from './JazzIcon';
|
||||
export { JazzIcon };
|
|
@ -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) => ({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -9,6 +9,7 @@ const linkify = LinkifyIt();
|
|||
|
||||
interface Props {
|
||||
text: string;
|
||||
isRss?: boolean;
|
||||
/** Allows you to customize now non-links are rendered. Simplest is just a <span>. */
|
||||
renderNonLink?: RenderTextCallbackType;
|
||||
}
|
||||
|
@ -22,12 +23,32 @@ export class Linkify extends React.Component<Props> {
|
|||
};
|
||||
|
||||
public render() {
|
||||
const { text, renderNonLink } = this.props;
|
||||
const matchData = linkify.match(text) || [];
|
||||
const { text, renderNonLink, isRss } = this.props;
|
||||
const results: Array<any> = [];
|
||||
let last = 0;
|
||||
let count = 1;
|
||||
|
||||
if (isRss && text.indexOf('</') !== -1) {
|
||||
results.push(
|
||||
<div
|
||||
key={count++}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: text
|
||||
.replace(
|
||||
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
||||
''
|
||||
)
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ''),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// should already have links
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const matchData = linkify.match(text) || [];
|
||||
let last = 0;
|
||||
|
||||
// We have to do this, because renderNonLink is not required in our Props object,
|
||||
// but it is always provided via defaultProps.
|
||||
if (!renderNonLink) {
|
||||
|
|
|
@ -48,6 +48,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}
|
||||
>
|
||||
• {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}`
|
||||
)}
|
||||
>
|
||||
• 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -304,6 +304,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
name={authorName}
|
||||
profileName={authorProfileName}
|
||||
i18n={i18n}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
10
tslint.json
10
tslint.json
|
@ -136,7 +136,15 @@
|
|||
// 'as' is nicer than angle brackets.
|
||||
"prefer-type-cast": false,
|
||||
// We use || and && shortcutting because we're javascript programmers
|
||||
"strict-boolean-expressions": false
|
||||
"strict-boolean-expressions": false,
|
||||
"react-no-dangerous-html": [
|
||||
true,
|
||||
{
|
||||
"file": "ts/components/conversation/Linkify.tsx",
|
||||
"method": "render",
|
||||
"comment": "Usage has been approved by Ryan Tharp on 2019-07-22"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rulesDirectory": ["node_modules/tslint-microsoft-contrib"]
|
||||
}
|
||||
|
|
113
yarn.lock
113
yarn.lock
|
@ -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==
|
||||
|
|
Loading…
Reference in New Issue