Finish new Message component, integrate into application

Also:
- New schema version 8 with video/image thumbnails, screenshots, sizes
- Upgrade messages not at current schema version when loading messages
  to show in conversation
- New MessageDetail react component
- New ConversationHeader react component
This commit is contained in:
Scott Nonnenberg 2018-07-09 14:29:13 -07:00
parent 69f11c4a7b
commit 3c69886320
102 changed files with 9644 additions and 7381 deletions

View File

@ -311,17 +311,21 @@ module.exports = grunt => {
});
}
grunt.registerTask('unit-tests', 'Run unit tests w/Electron', () => {
const environment = grunt.option('env') || 'test';
const done = this.async();
grunt.registerTask(
'unit-tests',
'Run unit tests w/Electron',
function thisNeeded() {
const environment = grunt.option('env') || 'test';
const done = this.async();
runTests(environment, done);
});
runTests(environment, done);
}
);
grunt.registerTask(
'lib-unit-tests',
'Run libtextsecure unit tests w/Electron',
() => {
function thisNeeded() {
const environment = grunt.option('env') || 'test-lib';
const done = this.async();
@ -329,82 +333,86 @@ module.exports = grunt => {
}
);
grunt.registerMultiTask('test-release', 'Test packaged releases', () => {
const dir = grunt.option('dir') || 'dist';
const environment = grunt.option('env') || 'production';
const config = this.data;
const archive = [dir, config.archive].join('/');
const files = [
'config/default.json',
`config/${environment}.json`,
`config/local-${environment}.json`,
];
grunt.registerMultiTask(
'test-release',
'Test packaged releases',
function thisNeeded() {
const dir = grunt.option('dir') || 'dist';
const environment = grunt.option('env') || 'production';
const config = this.data;
const archive = [dir, config.archive].join('/');
const files = [
'config/default.json',
`config/${environment}.json`,
`config/local-${environment}.json`,
];
console.log(this.target, archive);
const releaseFiles = files.concat(config.files || []);
releaseFiles.forEach(fileName => {
console.log(fileName);
try {
asar.statFile(archive, fileName);
return true;
} catch (e) {
console.log(e);
throw new Error(`Missing file ${fileName}`);
}
});
console.log(this.target, archive);
const releaseFiles = files.concat(config.files || []);
releaseFiles.forEach(fileName => {
console.log(fileName);
try {
asar.statFile(archive, fileName);
return true;
} catch (e) {
console.log(e);
throw new Error(`Missing file ${fileName}`);
}
});
if (config.appUpdateYML) {
const appUpdateYML = [dir, config.appUpdateYML].join('/');
if (fs.existsSync(appUpdateYML)) {
console.log('auto update ok');
} else {
throw new Error(`Missing auto update config ${appUpdateYML}`);
if (config.appUpdateYML) {
const appUpdateYML = [dir, config.appUpdateYML].join('/');
if (fs.existsSync(appUpdateYML)) {
console.log('auto update ok');
} else {
throw new Error(`Missing auto update config ${appUpdateYML}`);
}
}
const done = this.async();
// A simple test to verify a visible window is opened with a title
const { Application } = spectron;
const app = new Application({
path: [dir, config.exe].join('/'),
requireName: 'unused',
});
app
.start()
.then(() => app.client.getWindowCount())
.then(count => {
assert.equal(count, 1);
console.log('window opened');
})
.then(() =>
// Get the window's title
app.client.getTitle()
)
.then(title => {
// Verify the window's title
assert.equal(title, packageJson.productName);
console.log('title ok');
})
.then(() => {
assert(
app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1
);
console.log('environment ok');
})
.then(
() =>
// Successfully completed test
app.stop(),
error =>
// Test failed!
app.stop().then(() => {
grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`);
})
)
.then(done);
}
const done = this.async();
// A simple test to verify a visible window is opened with a title
const { Application } = spectron;
const app = new Application({
path: [dir, config.exe].join('/'),
requireName: 'unused',
});
app
.start()
.then(() => app.client.getWindowCount())
.then(count => {
assert.equal(count, 1);
console.log('window opened');
})
.then(() =>
// Get the window's title
app.client.getTitle()
)
.then(title => {
// Verify the window's title
assert.equal(title, packageJson.productName);
console.log('title ok');
})
.then(() => {
assert(
app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1
);
console.log('environment ok');
})
.then(
() =>
// Successfully completed test
app.stop(),
error =>
// Test failed!
app.stop().then(() => {
grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`);
})
)
.then(done);
});
);
grunt.registerTask('tx', ['exec:tx-pull', 'locale-patch']);
grunt.registerTask('dev', ['default', 'watch']);

View File

@ -279,7 +279,7 @@
}
},
"youMarkedAsVerified": {
"message": "You marked your safety number with $name$ as verified.",
"message": "You marked your Safety Number with $name$ as verified",
"description":
"Shown in the conversation history when the user marks a contact as verified.",
"placeholders": {
@ -290,9 +290,9 @@
}
},
"youMarkedAsNotVerified": {
"message": "You marked your safety number with $name$ as unverified.",
"message": "You marked your Safety Number with $name$ as not verified",
"description":
"Shown in the conversation history when the user marks a contact as not verified, whether on the safety number screen or by dismissing a banner or dialog.",
"Shown in the conversation history when the user marks a contact as not verified, whether on the Safety Number screen or by dismissing a banner or dialog.",
"placeholders": {
"name": {
"content": "$1",
@ -302,7 +302,7 @@
},
"youMarkedAsVerifiedOtherDevice": {
"message":
"You marked your safety number with $name$ as verified from another device.",
"You marked your Safety Number with $name$ as verified from another device",
"description":
"Shown in the conversation history when we discover that the user marked a contact as verified on another device.",
"placeholders": {
@ -314,7 +314,7 @@
},
"youMarkedAsNotVerifiedOtherDevice": {
"message":
"You marked your safety number with $name$ as not verified from another device.",
"You marked your Safety Number with $name$ as not verified from another device",
"description":
"Shown in the conversation history when we discover that the user marked a contact as not verified on another device.",
"placeholders": {
@ -473,7 +473,7 @@
"Your safety number with this contact has changed. This could either mean that someone is trying to intercept your communication, or this contact simply reinstalled Signal. You may wish to verify the new safety number below."
},
"incomingError": {
"message": "Error handling incoming message."
"message": "Error handling incoming message"
},
"media": {
"message": "Media",
@ -754,9 +754,6 @@
"error": {
"message": "Error"
},
"resend": {
"message": "Resend"
},
"messageDetail": {
"message": "Message Detail"
},
@ -767,7 +764,7 @@
"message":
"Are you sure? Clicking 'delete' will permanently remove this message from this device."
},
"deleteMessage": {
"deleteThisMessage": {
"message": "Delete this message"
},
"from": {
@ -823,6 +820,21 @@
"message":
"You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message."
},
"moreInfo": {
"message": "More Info...",
"description":
"Shown on the drop-down menu for an individual message, takes you to message detail screen"
},
"retrySend": {
"message": "Retry Send",
"description":
"Shown on the drop-down menu for an indinvidaul message, but only if it is an outgoing message that failed to send"
},
"deleteMessage": {
"message": "Delete Message",
"description":
"Shown on the drop-down menu for an individual message, deletes single message"
},
"deleteMessages": {
"message": "Delete messages",
"description": "Menu item for deleting messages, title case."
@ -842,10 +854,23 @@
"description":
"Used in alt tag of thumbnail images inside of an embedded message quote"
},
"imageFailedToLoad": {
"message": "Image failed to load",
"description": "When an image attachment is missing, this message is shown"
},
"videoScreenshotFailedToLoad": {
"message": "Video screenshot failed to load",
"description":
"When a attachment video screenshot is missing, this message is shown"
},
"imageAttachmentAlt": {
"message": "Image attached to message",
"description": "Used in alt tag of image attachment"
},
"videoAttachmentAlt": {
"message": "Screenshot of video attached to message",
"description": "Used in alt tag of video attachment preview"
},
"lightboxImageAlt": {
"message": "Image sent in conversation",
"description":
@ -866,11 +891,6 @@
}
}
},
"noContents": {
"message": "No message contents",
"description":
"Shown in a message bubble if we have nothing in the message to display, or a quote and nothing else"
},
"installWelcome": {
"message": "Welcome to Signal Desktop",
"description": "Welcome title on the install page"
@ -1032,15 +1052,9 @@
"description":
"Displayed in notifications when setting is 'name and message' and more than one message is waiting"
},
"messageNotSent": {
"message": "Message not sent.",
"description":
"Informational label, appears on messages that failed to send"
},
"someRecipientsFailed": {
"message": "Some recipients failed.",
"description":
"When you send to multiple recipients via a group, and the message went to some recipients but not others."
"sendFailed": {
"message": "Send failed",
"description": "Shown on outgoing message if it fails to send"
},
"showMore": {
"message": "Details",
@ -1159,7 +1173,7 @@
"description": "Brief message shown when trying to message a blocked number"
},
"youChangedTheTimer": {
"message": "You set the timer to $time$.",
"message": "You set the disappearing message timer to $time$",
"description":
"Message displayed when you change the message expiration timer in a conversation.",
"placeholders": {
@ -1170,7 +1184,7 @@
}
},
"timerSetOnSync": {
"message": "Updating timer to $time$.",
"message": "Updated disappearing message timer to $time$",
"description":
"Message displayed when timer is set on initial link of desktop device.",
"placeholders": {
@ -1181,7 +1195,7 @@
}
},
"theyChangedTheTimer": {
"message": "$name$ set the timer to $time$.",
"message": "$name$ set the disappearing message timer to $time$",
"description":
"Message displayed when someone else changes the message expiration timer in a conversation.",
"placeholders": {
@ -1334,9 +1348,15 @@
"message": "Play audio notification",
"description": "Description for audio notification setting"
},
"keychanged": {
"message": "Your safety number with $name$ has changed. Click to show.",
"description": "",
"safetyNumberChanged": {
"message": "Safety Number has changed",
"description":
"A notification shown in the conversation when a contact reinstalls"
},
"safetyNumberChangedGroup": {
"message": "Safety Number with $name$ has changed",
"description":
"A notification shown in a group conversation when a contact reinstalls, showing the contact name",
"placeholders": {
"name": {
"content": "$1",
@ -1344,6 +1364,11 @@
}
}
},
"verifyNewNumber": {
"message": "Verify New Number",
"description":
"Label on button included with safety number change notification in the conversation"
},
"yourSafetyNumberWith": {
"message": "Your safety number with $name$:",
"description": "Heading for safety number view",
@ -1405,7 +1430,7 @@
"message": "Later"
},
"leftTheGroup": {
"message": "$name$ left the group.",
"message": "$name$ left the group",
"description":
"Shown in the conversation history when a single person leaves the group",
"placeholders": {
@ -1415,13 +1440,24 @@
}
}
},
"multipleLeftTheGroup": {
"message": "$name$ left the group",
"description":
"Shown in the conversation history when multiple people leave the group",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice, Bob"
}
}
},
"updatedTheGroup": {
"message": "Updated the group.",
"message": "Group updated",
"description":
"Shown in the conversation history when someone updates the group"
},
"titleIsNow": {
"message": "Title is now '$name$'.",
"message": "Title is now '$name$'",
"description":
"Shown in the conversation history when someone changes the title of the group",
"placeholders": {
@ -1432,7 +1468,7 @@
}
},
"joinedTheGroup": {
"message": "$name$ joined the group.",
"message": "$name$ joined the group",
"description":
"Shown in the conversation history when a single person joins the group",
"placeholders": {
@ -1443,7 +1479,7 @@
}
},
"multipleJoinedTheGroup": {
"message": "$names$ joined the group.",
"message": "$names$ joined the group",
"description":
"Shown in the conversation history when more than one person joins the group",
"placeholders": {

View File

@ -136,7 +136,7 @@ exports.getRelativePath = name => {
return path.join(prefix, name);
};
// createAbsolutePathGetter :: RoothPath -> RelativePath -> AbsolutePath
// createAbsolutePathGetter :: RootPath -> RelativePath -> AbsolutePath
exports.createAbsolutePathGetter = rootPath => relativePath => {
const absolutePath = path.join(rootPath, relativePath);
const normalized = path.normalize(absolutePath);

View File

@ -23,9 +23,8 @@
<link href='images/icon_128.png' rel='shortcut icon'>
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
<!-- When making changes to these templates, be sure to update these two places:
1) test/styleguide/legacy_templates.js
2) test/index.html
<!--
When making changes to these templates, be sure to update test/index.html as well
-->
<script type='text/x-tmpl-mustache' id='app-loading-screen'>
@ -51,20 +50,20 @@
</script>
<script type='text/x-tmpl-mustache' id='two-column'>
<div class='gutter'>
<div class='network-status-container'></div>
<div class='title-bar active' id='header'>
<h1>Signal</h1>
<div class='tool-bar clearfix'>
<input type='search' class='search' placeholder='{{ searchForPeopleOrGroups }}' dir='auto'>
<span class='search-icon'></span>
</div>
</div>
<div class='content'>
<div class='conversations inbox'></div>
<div class='conversations search-results hide'>
<div class='new-contact contact hide'></div>
</div>
<div class='network-status-container'></div>
<div class='title-bar active'>
<div class='logo'>Signal</div>
</div>
<div class='tool-bar clearfix'>
<input type='search' class='search' placeholder='{{ searchForPeopleOrGroups }}' dir='auto'>
<span class='search-icon'></span>
</div>
<div class='content'>
<div class='conversations inbox'></div>
<div class='conversations search-results hide'>
<div class='new-contact contact hide'></div>
</div>
</div>
</div>
<div class='conversation-stack'>
<div class='conversation placeholder'>
@ -81,15 +80,14 @@
<div class='lightbox-container'></div>
</script>
<script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
<button class='text {{ cssClass }}' alt='{{ moreBelow }}'>
<div class='icon'></div>
<button class='text module-scroll-down__button {{ buttonClass }}' alt='{{ moreBelow }}'>
<div class='module-scroll-down__icon'></div>
</button>
</script>
<script type='text/x-tmpl-mustache' id='last-seen-indicator-view'>
<div class='bar'>
<div class='text'>
{{ unreadMessages }}
</div>
<div class='module-last-seen-indicator__bar'/>
<div class='module-last-seen-indicator__text'>
{{ unreadMessages }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='expired_alert'>
@ -112,46 +110,7 @@
<p> {{ content }}</p>
</script>
<script type='text/x-tmpl-mustache' id='conversation'>
<div class='conversation-header {{ avatar.color }}'>
<div class='header-buttons left'>
<div class='vertical-align'>
<button class='back hide'></button>
</div>
</div>
<div class='header-buttons right'>
<div class='vertical-align'>
<div class='conversation-menu menu'>
<button class='hamburger' alt='conversation menu'></button>
<ul class='menu-list'>
<li class='disappearing-messages'>{{ disappearing-messages }}</li>
<li class='view-all-media'>{{ view-all-media }}</li>
{{#group}}
<li class='show-members'>{{ show-members }}</li>
<!-- <li class='update-group'>Update group</li> -->
<!-- <li class='leave-group'>Leave group</li> -->
{{/group}}
{{^group}}
{{ ^isMe }}
<li class='show-identity'>{{ show-identity }}</li>
{{ /isMe }}
<li class='end-session'>{{ end-session }}</li>
{{/group}}
<li class='destroy'>{{ destroy }}</li>
</ul>
</div>
<div class='timer-menu menu'>
<button class='clock' alt='timer menu'></button>
<ul class='menu-list'>
{{ #timer_options }}
<li data-seconds={{ attributes.seconds }}>{{ getName }}</li>
{{ /timer_options }}
</ul>
</div>
</div>
</div>
<span class='conversation-title'></span>
{{> avatar }}
</div>
<div class='conversation-header'></div>
<div class='main panel'>
<div class='discussion-container'>
<div class='bar-container hide'>
@ -217,65 +176,6 @@
<div class='fileSize'>{{ fileSize }}</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='hasRetry'>
{{ messageNotSent }}
<span href='#' class='retry'>{{ resend }}</span>
</script>
<script type='text/x-tmpl-mustache' id='some-failed'>
{{ someFailed }}
</script>
<script type='text/x-tmpl-mustache' id='keychange'>
<span class='content' dir='auto'><span class='shield icon'></span> {{ content }}</span>
</script>
<script type='text/x-tmpl-mustache' id='verified-change'>
<span class='content' dir='auto'><span class='{{ icon }} icon'></span> {{ content }}</span>
</script>
<script type='text/x-tmpl-mustache' id='message'>
{{> avatar }}
<div class='bubble {{ avatar.color }}'>
<div class='sender' dir='auto'>
{{ sender }}
{{ #profileName }}
<span class='profileName'>{{ profileName }} </span>
{{ /profileName }}
</div>
<div class='tail-wrapper {{ innerBubbleClasses }}'>
<div class='inner-bubble'>
{{ #hasAttachments }}
<div class='attachments'></div>
{{ /hasAttachments }}
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'></div>
{{ /message }}
</div>
{{ /hasBody }}
</div>
</div>
<div class='meta'>
<span class='timestamp' data-timestamp={{ timestamp }}></span>
<span class='status hide'></span>
<span class='timer'></span>
</div>
{{ #hoverIcon }}
<div class='menu-container menu'>
<div class='menu-anchor'>
<span class='dots-horizontal-icon'></span>
<ul class='menu-list'>
<li class='reply'>{{ reply }}</li>
</ul>
</div>
</div>
{{ /hoverIcon }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='hourglass'>
<span class='hourglass'><span class='sand'></span></span>
</script>
<script type='text/x-tmpl-mustache' id='expirationTimerUpdate'>
<span class='content'><span class='icon clock'></span> {{ content }}</span>
</script>
<script type='text/x-tmpl-mustache' id='new-group-update'>
<div class='conversation-header'>
<button class='back'></button>
@ -357,51 +257,6 @@
<script type='text/x-tmpl-mustache' id='attachment-type-modal'>
Sorry, your attachment has a type, {{type}}, that is not currently supported.
</script>
<script type='text/x-tmpl-mustache' id='message-detail'>
<div class='container'>
<div class='message-container'></div>
<div class='info'>
<table>
{{ #errors }}
<tr>
<td class='label'>{{ errorLabel }}</td>
<td> <span class='error-message'>{{message}}</span> </td>
</tr>
{{ /errors }}
<tr>
<td class='label'>{{ sent }}</td>
<td> {{ sent_at }}</td>
</tr>
{{ #received_at }}
<tr>
<td class='label'>{{ received }}</td>
<td> {{ received_at }}</td>
</tr>
{{ /received_at }}
<tr> <td class='tofrom label'>{{tofrom}}</td> </tr>
</table>
<div class='contacts'>
</div>
</div>
<div class='delete-container'>
<button class='delete grey'>{{ deleteLabel }}</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='identity-key-send-error'>
<div class='container'>
<div class='explanation'>
{{ errorExplanation }}
</div>
<div class='safety-number'>
<button class='show-safety-number grey'>{{ showSafetyNumber }}</button>
</div>
<div class='actions'>
<button class='send-anyway grey'>{{ sendAnyway }}</button>
<button class='cancel grey'>{{ cancel }}</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='group-member-list'>
<div class='container'>
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
@ -478,39 +333,6 @@
<span class='error-message'>{{message}}</span>
{{ /message }}
</script>
<script type='text/x-tmpl-mustache' id='contact-detail'>
<div class='clearfix'>
{{> avatar }}
<div class='contact-details'>
{{ #errors }}
<div class='error-icon-container'>
{{ #showErrorButton }}
<button class='error'>
<span class='icon error'></span>
{{ errorButtonLabel }}
</button>
{{ /showErrorButton }}
{{ ^showErrorButton }}
<span class='error-icon'></span>
{{ /showErrorButton }}
</div>
{{ /errors }}
{{ ^errors }}
<div class='status-icon-container {{ status }}'>
<span class='status'></span>
</div>
{{ /errors }}
<span class='name' dir='auto'>{{ name }}</span>
{{ #errors }}
{{ #message }}
<p class='error-message'>{{message}}</p>
{{ /message }}
{{ /errors }}
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='link_to_support'>
<a href='http://support.signal.org/hc/articles/213134107' target='_blank'>
{{ learnMore }}
@ -805,7 +627,6 @@
<script type='text/javascript' src='js/views/timestamp_view.js'></script>
<script type='text/javascript' src='js/views/message_view.js'></script>
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
<script type='text/javascript' src='js/views/message_detail_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script>
@ -818,7 +639,6 @@
<script type='text/javascript' src='js/views/identicon_svg_view.js'></script>
<script type='text/javascript' src='js/views/install_view.js'></script>
<script type='text/javascript' src='js/views/banner_view.js'></script>
<script type='text/javascript' src='js/views/identity_key_send_error_view.js'></script>
<script type="text/javascript" src="js/views/phone-input-view.js"></script>
<script type='text/javascript' src='js/views/standalone_registration_view.js'></script>
<script type='text/javascript' src='js/views/app_view.js'></script>

22
images/download.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Download/download-24</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M19,9 L15,9 L15,3 L9,3 L9,9 L5,9 L12,16 L19,9 Z M5,18 L5,20 L19,20 L19,18 L5,18 Z" id="path-1"></path>
<rect id="path-3" x="0" y="0" width="24" height="24"></rect>
</defs>
<g id="Icons/Download/download-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Shape" fill-rule="nonzero"></g>
<g id="Color/Dark/Black" mask="url(#mask-2)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

22
images/ellipsis.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Ellipses/ellipses-24</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M6,10 C4.9,10 4,10.9 4,12 C4,13.1 4.9,14 6,14 C7.1,14 8,13.1 8,12 C8,10.9 7.1,10 6,10 Z M18,10 C16.9,10 16,10.9 16,12 C16,13.1 16.9,14 18,14 C19.1,14 20,13.1 20,12 C20,10.9 19.1,10 18,10 Z M12,10 C10.9,10 10,10.9 10,12 C10,13.1 10.9,14 12,14 C13.1,14 14,13.1 14,12 C14,10.9 13.1,10 12,10 Z" id="path-1"></path>
<rect id="path-3" x="0" y="0" width="24" height="24"></rect>
</defs>
<g id="Icons/Ellipses/ellipses-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="Shape" fill="#62656A" fill-rule="nonzero" xlink:href="#path-1"></use>
<g id="Color/Dark/Black" mask="url(#mask-2)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

10
images/error.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Error/error-20</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Error/error-20" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M10,0 C15.52,0 20,4.48 20,10 C20,15.52 15.52,20 10,20 C4.48,20 0,15.52 0,10 C0,4.48 4.48,0 10,0 Z M10,1.5 C5.308,1.5 1.5,5.308 1.5,10 C1.5,14.692 5.308,18.5 10,18.5 C14.692,18.5 18.5,14.692 18.5,10 C18.5,5.308 14.692,1.5 10,1.5 Z M9.25,5 L10.75,5 L10.75,12 L9.25,12 L9.25,5 Z M9.25,13.5 L10.75,13.5 L10.75,15 L9.25,15 L9.25,13.5 Z" id="Combined-Shape" fill="#000000" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 887 B

22
images/gear.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 51 (57462) - http://www.bohemiancoding.com/sketch -->
<title>Gear/gear-20</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M17.43,10.98 C17.47,10.66 17.5,10.34 17.5,10 C17.5,9.66 17.47,9.34 17.43,9.02 L19.54,7.37 C19.73,7.22 19.78,6.95 19.66,6.73 L17.66,3.27 C17.54,3.05 17.27,2.97 17.05,3.05 L14.56,4.05 C14.04,3.65 13.48,3.32 12.87,3.07 L12.49,0.42 C12.46,0.18 12.25,0 12,0 L8,0 C7.75,0 7.54,0.18 7.51,0.42 L7.13,3.07 C6.52,3.32 5.96,3.66 5.44,4.05 L2.95,3.05 C2.72,2.96 2.46,3.05 2.34,3.27 L0.34,6.73 C0.21,6.95 0.27,7.22 0.46,7.37 L2.57,9.02 C2.53,9.34 2.5,9.67 2.5,10 C2.5,10.33 2.53,10.66 2.57,10.98 L0.46,12.63 C0.27,12.78 0.22,13.05 0.34,13.27 L2.34,16.73 C2.46,16.95 2.73,17.03 2.95,16.95 L5.44,15.95 C5.96,16.35 6.52,16.68 7.13,16.93 L7.51,19.58 C7.54,19.82 7.75,20 8,20 L12,20 C12.25,20 12.46,19.82 12.49,19.58 L12.87,16.93 C13.48,16.68 14.04,16.34 14.56,15.95 L17.05,16.95 C17.28,17.04 17.54,16.95 17.66,16.73 L19.66,13.27 C19.78,13.05 19.73,12.78 19.54,12.63 L17.43,10.98 Z M10,13.5 C8.07,13.5 6.5,11.93 6.5,10 C6.5,8.07 8.07,6.5 10,6.5 C11.93,6.5 13.5,8.07 13.5,10 C13.5,11.93 11.93,13.5 10,13.5 Z" id="path-1"></path>
<rect id="path-3" x="0" y="0" width="40" height="40"></rect>
</defs>
<g id="Gear/gear-20" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Shape" fill-rule="nonzero"></g>
<g id="Primitives/Color/Black" mask="url(#mask-2)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

22
images/read.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="12px" viewBox="0 0 18 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Read/read-18x12</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M7.91731278,0.313257194 C6.15053376,1.58392424 5,3.65760134 5,6 C5,6.343797 5.0247846,6.68180525 5.07266453,7.01233547 L5,7.085 L3.205,5.295 L2.5,6 L5,8.5 L5.33970233,8.16029767 C5.80439817,9.59399486 6.71914823,10.8250231 7.91731278,11.6867428 C7.31518343,11.8898758 6.67037399,12 6,12 C2.688,12 0,9.312 0,6 C0,2.688 2.688,0 6,0 C6.67037399,0 7.31518343,0.110124239 7.91731278,0.313257194 Z M12,0 C15.312,0 18,2.688 18,6 C18,9.312 15.312,12 12,12 C8.688,12 6,9.312 6,6 C6,2.688 8.688,0 12,0 Z M11,8.5 L15.5,4 L14.795,3.29 L11,7.085 L9.205,5.295 L8.5,6 L11,8.5 Z" id="path-1"></path>
<rect id="path-3" x="0" y="0" width="18" height="12"></rect>
</defs>
<g id="Icons/Read/read-18x12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="double-check" fill-rule="nonzero"></g>
<g id="Color/Dark/Black" mask="url(#mask-2)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

22
images/reply.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Reply/reply-24</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M10,8 L10,4 L3,11 L10,18 L10,13.9 C15,13.9 18.5,15.5 21,19 C20,14 17,9 10,8 Z" id="path-1"></path>
<rect id="path-3" x="0" y="0" width="24" height="24"></rect>
</defs>
<g id="Icons/Reply/reply-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Shape" fill-rule="nonzero"></g>
<g id="Color/Dark/Black" mask="url(#mask-2)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

14
images/timer.svg Normal file
View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20">
<defs>
<path id="a" d="M10 1.5c-.338 0-.672.02-1 .058V.05C9.329.017 9.663 0 10 0c5.523 0 10 4.477 10 10s-4.477 10-10 10S0 15.523 0 10A10 10 0 0 1 5.658.99l.487.843.005-.002 4.5 7.794-1.3.75-4.233-7.333A8.5 8.5 0 1 0 10 1.5z"/>
<path id="c" d="M0 0h40v40H0z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="b" fill="#fff">
<use xlink:href="#a"/>
</mask>
<g mask="url(#b)">
<use fill="#62656A" xlink:href="#c"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 650 B

View File

@ -116,6 +116,9 @@
function mapOldThemeToNew(theme) {
switch (theme) {
case 'dark':
case 'light':
return theme;
case 'android-dark':
return 'dark';
case 'android':

View File

@ -5,7 +5,6 @@
/* global ConversationController: false */
/* global libsignal: false */
/* global Signal: false */
/* global storage: false */
/* global textsecure: false */
/* global Whisper: false */
@ -19,7 +18,15 @@
window.Whisper = window.Whisper || {};
const { Message } = window.Signal.Types;
const { Util } = window.Signal;
const { GoogleChrome } = Util;
const {
Conversation,
Contact,
Errors,
Message,
VisualAttachment,
} = window.Signal.Types;
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
// TODO: Factor out private and group subclasses of Conversation
@ -108,7 +115,10 @@
this.on('change:profileKey', this.onChangeProfileKey);
this.on('destroy', this.revokeAvatarUrl);
// Listening for out-of-band data updates
this.on('newmessage', this.addSingleMessage);
this.on('delivered', this.updateMessage);
this.on('read', this.updateMessage);
this.on('expired', this.onExpired);
this.listenTo(
this.messageCollection,
@ -127,6 +137,7 @@
mine.trigger('expired', mine);
}
},
async onExpiredCollection(message) {
console.log('onExpiredCollection', message.attributes);
const removeMessage = () => {
@ -144,6 +155,12 @@
removeMessage();
},
// Used to update existing messages when updated from out-of-band db access,
// like read and delivery receipts.
updateMessage(message) {
this.messageCollection.add(message, { merge: true });
},
addSingleMessage(message) {
const model = this.messageCollection.add(message, { merge: true });
model.setToExpire();
@ -716,24 +733,23 @@
},
async makeThumbnailAttachment(attachment) {
const { arrayBufferToObjectURL } = Util;
const attachmentWithData = await loadAttachmentData(attachment);
const { data, contentType } = attachmentWithData;
const objectUrl = Signal.Util.arrayBufferToObjectURL({
const objectUrl = arrayBufferToObjectURL({
data,
type: contentType,
});
const thumbnail = Signal.Util.GoogleChrome.isImageTypeSupported(
contentType
)
? await Whisper.FileInputView.makeImageThumbnail(128, objectUrl)
: await Whisper.FileInputView.makeVideoThumbnail(128, objectUrl);
const thumbnail = GoogleChrome.isImageTypeSupported(contentType)
? await VisualAttachment.makeImageThumbnail(128, objectUrl)
: await VisualAttachment.makeVideoThumbnail(128, objectUrl);
URL.revokeObjectURL(objectUrl);
const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
const finalContentType = 'image/png';
const finalObjectUrl = Signal.Util.arrayBufferToObjectURL({
const finalObjectUrl = arrayBufferToObjectURL({
data: arrayBuffer,
type: finalContentType,
});
@ -746,7 +762,7 @@
},
async makeQuote(quotedMessage) {
const { getName } = Signal.Types.Contact;
const { getName } = Contact;
const contact = quotedMessage.getContact();
const attachments = quotedMessage.get('attachments');
@ -765,8 +781,8 @@
(attachments || []).map(async attachment => {
const { contentType } = attachment;
const willMakeThumbnail =
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
GoogleChrome.isImageTypeSupported(contentType) ||
GoogleChrome.isVideoTypeSupported(contentType);
const makeThumbnail = async () => {
try {
if (willMakeThumbnail) {
@ -873,16 +889,14 @@
const lastMessage = collection.at(0);
const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null;
const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate(
{
currentLastMessageText: this.get('lastMessage') || null,
currentTimestamp: this.get('timestamp') || null,
lastMessage: lastMessageJSON,
lastMessageNotificationText: lastMessage
? lastMessage.getNotificationText()
: null,
}
);
const lastMessageUpdate = Conversation.createLastMessageUpdate({
currentLastMessageText: this.get('lastMessage') || null,
currentTimestamp: this.get('timestamp') || null,
lastMessage: lastMessageJSON,
lastMessageNotificationText: lastMessage
? lastMessage.getNotificationText()
: null,
});
console.log('Conversation: Update last message:', {
id: this.idForLogging() || null,
@ -1284,8 +1298,8 @@
return (
thumbnail ||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
GoogleChrome.isImageTypeSupported(contentType) ||
GoogleChrome.isVideoTypeSupported(contentType)
);
},
forceRender(message) {
@ -1323,8 +1337,8 @@
}
if (
!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)
!GoogleChrome.isImageTypeSupported(first.contentType) &&
!GoogleChrome.isVideoTypeSupported(first.contentType)
) {
return false;
}
@ -1352,7 +1366,7 @@
} catch (error) {
console.log(
'Problem loading attachment data for quoted message from database',
Signal.Types.Errors.toLogFormat(error)
Errors.toLogFormat(error)
);
return false;
}
@ -1370,8 +1384,8 @@
}
if (
!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)
!GoogleChrome.isImageTypeSupported(first.contentType) &&
!GoogleChrome.isVideoTypeSupported(first.contentType)
) {
return;
}
@ -1410,7 +1424,7 @@
try {
const thumbnailWithData = await loadAttachmentData(thumbnail);
const { data, contentType } = thumbnailWithData;
thumbnailWithData.objectUrl = Signal.Util.arrayBufferToObjectURL({
thumbnailWithData.objectUrl = Util.arrayBufferToObjectURL({
data,
type: contentType,
});
@ -1489,10 +1503,30 @@
return Promise.all(promises);
},
async upgradeMessages(messages) {
for (let max = messages.length, i = 0; i < max; i += 1) {
const message = messages.at(i);
const { attributes } = message;
const { schemaVersion } = attributes;
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
const upgradedMessage = upgradeMessageSchema(attributes);
message.set(upgradedMessage);
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
await wrapDeferred(message.save());
}
}
},
async fetchMessages() {
if (!this.id) {
throw new Error('This conversation has no id!');
}
if (this.inProgressFetch) {
console.log('Attempting to start a parallel fetchMessages() call');
return;
}
this.inProgressFetch = this.messageCollection.fetchConversation(
this.id,
@ -1501,11 +1535,24 @@
);
await this.inProgressFetch;
this.inProgressFetch = null;
try {
// We are now doing the work to upgrade messages before considering the load from
// the database complete. Note that we do save messages back, so it is a
// one-time hit. We do this so we have guarantees about message structure.
await this.upgradeMessages(this.messageCollection);
} catch (error) {
console.log(
'fetchMessages: failed to upgrade messages',
Errors.toLogFormat(error)
);
}
// We kick this process off, but don't wait for it. If async updates happen on a
// given Message, 'change' will be triggered
this.processQuotes(this.messageCollection);
this.inProgressFetch = null;
},
hasMember(number) {
@ -1534,28 +1581,36 @@
});
},
destroyMessages() {
this.messageCollection
.fetch({
index: {
// 'conversation' index on [conversationId, received_at]
name: 'conversation',
lower: [this.id],
upper: [this.id, Number.MAX_VALUE],
},
})
.then(() => {
const { models } = this.messageCollection;
this.messageCollection.reset([]);
_.each(models, message => {
message.destroy();
});
this.save({
lastMessage: null,
timestamp: null,
active_at: null,
});
async destroyMessages() {
let loaded;
do {
// Yes, we really want the await in the loop. We're deleting 100 at a
// time so we don't use too much memory.
// eslint-disable-next-line no-await-in-loop
await wrapDeferred(
this.messageCollection.fetch({
limit: 100,
index: {
// 'conversation' index on [conversationId, received_at]
name: 'conversation',
lower: [this.id],
upper: [this.id, Number.MAX_VALUE],
},
})
);
loaded = this.messageCollection.models;
this.messageCollection.reset([]);
_.each(loaded, message => {
message.destroy();
});
} while (loaded.length > 0);
this.save({
lastMessage: null,
timestamp: null,
active_at: null,
});
},
getName() {
@ -1646,20 +1701,8 @@
}
},
getColor() {
const title = this.get('name');
let color = this.get('color');
if (!color) {
if (this.isPrivate()) {
if (title) {
color = COLORS[Math.abs(this.hashCode()) % 15];
} else {
color = 'grey';
}
} else {
color = 'default';
}
}
return color;
const { migrateColor } = Util;
return migrateColor(this.get('color'));
},
getAvatar() {
if (this.avatarUrl === undefined) {
@ -1705,9 +1748,7 @@
const messageJSON = message.toJSON();
const messageSentAt = messageJSON.sent_at;
const messageId = message.id;
const isExpiringMessage = Signal.Types.Message.hasExpiration(
messageJSON
);
const isExpiringMessage = Message.hasExpiration(messageJSON);
console.log('Add notification', {
conversationId: this.idForLogging(),

View File

@ -1,6 +1,7 @@
/* global _: false */
/* global Backbone: false */
/* global storage: false */
/* global filesize: false */
/* global ConversationController: false */
/* global getAccountManager: false */
/* global i18n: false */
@ -17,8 +18,41 @@
window.Whisper = window.Whisper || {};
const { Message: TypedMessage, Contact } = Signal.Types;
const { deleteAttachmentData } = Signal.Migrations;
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
const {
// loadAttachmentData,
deleteAttachmentData,
getAbsoluteAttachmentPath,
} = Signal.Migrations;
window.AccountCache = Object.create(null);
window.AccountJobs = Object.create(null);
window.doesAcountCheckJobExist = number =>
Boolean(window.AccountJobs[number]);
window.checkForSignalAccount = number => {
if (window.AccountJobs[number]) {
return window.AccountJobs[number];
}
// eslint-disable-next-line more/no-then
const job = textsecure.messaging
.getProfile(number)
.then(() => {
window.AccountCache[number] = true;
})
.catch(() => {
window.AccountCache[number] = false;
});
window.AccountJobs[number] = job;
return job;
};
window.isSignalAccountCheckComplete = number =>
window.AccountCache[number] !== undefined;
window.hasSignalAccount = number => window.AccountCache[number];
window.Whisper.Message = Backbone.Model.extend({
database: Whisper.Database,
@ -28,6 +62,8 @@
this.set(TypedMessage.initializeSchemaVersion(attributes));
}
this.OUR_NUMBER = textsecure.storage.user.getNumber();
this.on('change:attachments', this.updateImageUrl);
this.on('destroy', this.onDestroy);
this.on('change:expirationStartTimestamp', this.setToExpire);
@ -113,7 +149,7 @@
return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left));
}
const messages = [i18n('updatedTheGroup')];
const messages = [];
if (groupUpdate.name) {
messages.push(i18n('titleIsNow', groupUpdate.name));
}
@ -129,7 +165,7 @@
}
}
return messages.join(' ');
return messages.join(', ');
}
if (this.isEndSession()) {
return i18n('sessionEnded');
@ -139,6 +175,9 @@
}
return this.get('body');
},
isVerifiedChange() {
return this.get('type') === 'verified-change';
},
isKeyChange() {
return this.get('type') === 'keychange';
},
@ -158,8 +197,12 @@
);
}
if (this.isKeyChange()) {
const conversation = this.getModelForKeyChange();
return i18n('keychanged', conversation.getTitle());
const phoneNumber = this.get('key_changed');
const conversation = this.findContact(phoneNumber);
return i18n(
'safetyNumberChangedGroup',
conversation ? conversation.getTitle() : null
);
}
const contacts = this.get('contact');
if (contacts && contacts.length) {
@ -252,6 +295,268 @@
thumbnail: thumbnailWithObjectUrl,
});
},
getPropsForTimerNotification() {
const { expireTimer, fromSync, source } = this.get(
'expirationTimerUpdate'
);
const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0);
const basicProps = {
type: 'fromOther',
...this.findAndFormatContact(source),
timespan,
};
if (source === this.OUR_NUMBER) {
return {
...basicProps,
type: 'fromMe',
};
} else if (fromSync) {
return {
...basicProps,
type: 'fromSync',
};
}
return basicProps;
},
getPropsForSafetyNumberNotification() {
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const phoneNumber = this.get('key_changed');
const onVerify = () =>
this.trigger('show-identity', this.findContact(phoneNumber));
return {
isGroup,
contact: this.findAndFormatContact(phoneNumber),
onVerify,
};
},
getPropsForVerificationNotification() {
const type = this.get('verified') ? 'markVerified' : 'markNotVerified';
const isLocal = this.get('local');
const phoneNumber = this.get('verifiedChanged');
return {
type,
isLocal,
contact: this.findAndFormatContact(phoneNumber),
};
},
getPropsForResetSessionNotification() {
// It doesn't need anything right now!
return {};
},
findContact(phoneNumber) {
return ConversationController.get(phoneNumber);
},
findAndFormatContact(phoneNumber) {
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
const contactModel = this.findContact(phoneNumber);
const avatar = contactModel ? contactModel.getAvatar() : null;
const color = contactModel ? contactModel.getColor() : null;
return {
phoneNumber: format(phoneNumber, {
ourRegionCode: regionCode,
}),
color,
avatarPath: avatar ? avatar.url : null,
name: contactModel ? contactModel.getName() : null,
profileName: contactModel ? contactModel.getProfileName() : null,
title: contactModel ? contactModel.getTitle() : null,
};
},
getPropsForGroupNotification() {
const groupUpdate = this.get('group_update');
const changes = [];
if (!groupUpdate.name && !groupUpdate.left && !groupUpdate.joined) {
changes.push({
type: 'general',
});
}
if (groupUpdate.joined) {
changes.push({
type: 'add',
contacts: _.map(
Array.isArray(groupUpdate.joined)
? groupUpdate.joined
: [groupUpdate.joined],
phoneNumber => this.findAndFormatContact(phoneNumber)
),
});
}
if (groupUpdate.left === 'You') {
changes.push({
type: 'remove',
isMe: true,
});
} else if (groupUpdate.left) {
changes.push({
type: 'remove',
contacts: _.map(
Array.isArray(groupUpdate.left)
? groupUpdate.left
: [groupUpdate.left],
phoneNumber => this.findAndFormatContact(phoneNumber)
),
});
}
if (groupUpdate.name) {
changes.push({
type: 'name',
newName: groupUpdate.name,
});
}
return {
changes,
};
},
getMessagePropStatus() {
if (this.hasErrors()) {
return 'error';
}
const readBy = this.get('read_by') || [];
if (readBy.length > 0) {
return 'read';
}
const delivered = this.get('delivered');
const deliveredTo = this.get('delivered_to') || [];
if (delivered || deliveredTo.length > 0) {
return 'delivered';
}
const sent = this.get('sent');
const sentTo = this.get('sent_to') || [];
if (sent || sentTo.length > 0) {
return 'sent';
}
return 'sending';
},
getPropsForMessage() {
const phoneNumber = this.getSource();
const contact = this.findAndFormatContact(phoneNumber);
const contactModel = this.findContact(phoneNumber);
const authorColor = contactModel ? contactModel.getColor() : null;
const authorAvatar = contactModel ? contactModel.getAvatar() : null;
const authorAvatarPath = authorAvatar.url;
const expirationLength = this.get('expireTimer') * 1000;
const expireTimerStart = this.get('expirationStartTimestamp');
const expirationTimestamp =
expirationLength && expireTimerStart
? expireTimerStart + expirationLength
: null;
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const attachments = this.get('attachments');
const firstAttachment = attachments && attachments[0];
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
id: this.id,
direction: this.isIncoming() ? 'incoming' : 'outgoing',
timestamp: this.get('sent_at'),
status: this.getMessagePropStatus(),
contact: this.getPropsForEmbeddedContact(),
authorName: contact.name,
authorProfileName: contact.profileName,
authorPhoneNumber: contact.phoneNumber,
authorColor,
conversationType: isGroup ? 'group' : 'direct',
attachment: this.getPropsForAttachment(firstAttachment),
quote: this.getPropsForQuote(),
authorAvatarPath,
expirationLength,
expirationTimestamp,
onReply: () => this.trigger('reply', this),
onRetrySend: () => this.retrySend(),
onShowDetail: () => this.trigger('show-message-detail', this),
onDelete: () => this.trigger('delete', this),
onClickAttachment: () =>
this.trigger('show-lightbox', {
attachment: firstAttachment,
message: this,
}),
onDownload: () =>
this.trigger('download', {
attachment: firstAttachment,
message: this,
}),
};
},
createNonBreakingLastSeparator(text) {
if (!text) {
return null;
}
const nbsp = '\xa0';
const regex = /(\S)( +)(\S+\s*)$/;
return text.replace(regex, (match, start, spaces, end) => {
const newSpaces = _.reduce(
spaces,
accumulator => accumulator + nbsp,
''
);
return `${start}${newSpaces}${end}`;
});
},
getPropsForEmbeddedContact() {
const regionCode = storage.get('regionCode');
const { contactSelector } = Contact;
const contacts = this.get('contact');
if (!contacts || !contacts.length) {
return null;
}
const contact = contacts[0];
const firstNumber =
contact.number && contact.number[0] && contact.number[0].value;
const onSendMessage = firstNumber
? () => {
this.trigger('open-conversation', firstNumber);
}
: null;
const onClick = async () => {
// First let's be sure that the signal account check is complete.
await window.checkForSignalAccount(firstNumber);
this.trigger('show-contact-detail', {
contact,
hasSignalAccount: window.hasSignalAccount(firstNumber),
});
};
// Would be nice to do this before render, on initial load of message
if (!window.isSignalAccountCheckComplete(firstNumber)) {
window.checkForSignalAccount(firstNumber).then(() => {
this.trigger('change');
});
}
return contactSelector(contact, {
regionCode,
getAbsoluteAttachmentPath,
onSendMessage,
onClick,
hasSignalAccount: window.hasSignalAccount(firstNumber),
});
},
getPropsForQuote() {
const quote = this.get('quote');
if (!quote) {
@ -259,15 +564,14 @@
}
const objectUrl = this.getQuoteObjectUrl();
const OUR_NUMBER = textsecure.storage.user.getNumber();
const { author } = quote;
const contact = this.getQuoteContact();
const authorTitle = contact ? contact.getTitle() : author;
const authorPhoneNumber = author;
const authorProfileName = contact ? contact.getProfileName() : null;
const authorName = contact ? contact.getName() : null;
const authorColor = contact ? contact.getColor() : 'grey';
const isFromMe = contact ? contact.id === OUR_NUMBER : false;
const isIncoming = this.isIncoming();
const isFromMe = contact ? contact.id === this.OUR_NUMBER : false;
const onClick = () => {
const { quotedMessage } = this;
if (quotedMessage) {
@ -275,55 +579,150 @@
}
};
const firstAttachment = quote.attachments && quote.attachments[1];
return {
attachments: (quote.attachments || []).map(attachment =>
this.processAttachment(attachment, objectUrl)
),
authorColor,
authorProfileName,
authorTitle,
text: this.createNonBreakingLastSeparator(quote.text),
attachment: firstAttachment
? this.processAttachment(firstAttachment, objectUrl)
: null,
isFromMe,
isIncoming,
authorPhoneNumber,
authorProfileName,
authorName,
authorColor,
onClick: this.quotedMessage ? onClick : null,
text: quote.text,
};
},
getPropsForAttachment(attachment) {
if (!attachment) {
return null;
}
const { path, flags, size, screenshot, thumbnail } = attachment;
return {
...attachment,
fileSize: size ? filesize(size) : null,
isVoiceMessage:
flags &&
// eslint-disable-next-line no-bitwise
flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
url: getAbsoluteAttachmentPath(path),
screenshot: screenshot
? {
...screenshot,
url: getAbsoluteAttachmentPath(screenshot.path),
}
: null,
thumbnail: thumbnail
? {
...thumbnail,
url: getAbsoluteAttachmentPath(thumbnail.path),
}
: null,
};
},
getPropsForMessageDetail() {
const newIdentity = i18n('newIdentity');
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
// Older messages don't have the recipients included on the message, so we fall
// back to the conversation's current recipients
const phoneNumbers = this.isIncoming()
? [this.get('source')]
: this.get('recipients') || this.conversation.getRecipients();
// This will make the error message for outgoing key errors a bit nicer
const allErrors = (this.get('errors') || []).map(error => {
if (error.name === OUTGOING_KEY_ERROR) {
// eslint-disable-next-line no-param-reassign
error.message = newIdentity;
}
return error;
});
// If an error has a specific number it's associated with, we'll show it next to
// that contact. Otherwise, it will be a standalone entry.
const errors = _.reject(allErrors, error => Boolean(error.number));
const errorsGroupedById = _.groupBy(allErrors, 'number');
const finalContacts = (phoneNumbers || []).map(id => {
const errorsForContact = errorsGroupedById[id];
const isOutgoingKeyError = Boolean(
_.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR)
);
return {
...this.findAndFormatContact(id),
status: this.getStatus(id),
errors: errorsForContact,
isOutgoingKeyError,
onSendAnyway: () =>
this.trigger('force-send', {
contact: this.findContact(id),
message: this,
}),
onShowSafetyNumber: () =>
this.trigger('show-identity', this.findContact(id)),
};
});
// The prefix created here ensures that contacts with errors are listed
// first; otherwise it's alphabetical
const sortedContacts = _.sortBy(
finalContacts,
contact => `${contact.errors ? '0' : '1'}${contact.title}`
);
return {
sentAt: this.get('sent_at'),
receivedAt: this.get('received_at'),
message: {
...this.getPropsForMessage(),
disableMenu: true,
// To ensure that group avatar doesn't show up
conversationType: 'direct',
},
errors,
contacts: sortedContacts,
};
},
retrySend() {
const retries = _.filter(
this.get('errors'),
this.isReplayableError.bind(this)
);
_.map(retries, 'number').forEach(number => {
this.resend(number);
});
},
getConversation() {
// This needs to be an unsafe call, because this method is called during
// initial module setup. We may be in the middle of the initial fetch to
// the database.
return ConversationController.getUnsafe(this.get('conversationId'));
},
getExpirationTimerUpdateSource() {
if (!this.isExpirationTimerUpdate()) {
throw new Error('Message is not a timer update!');
getIncomingContact() {
if (!this.isIncoming()) {
return null;
}
const source = this.get('source');
if (!source) {
return null;
}
const conversationId = this.get('expirationTimerUpdate').source;
return ConversationController.getOrCreate(conversationId, 'private');
return ConversationController.getOrCreate(source, 'private');
},
getSource() {
if (this.isIncoming()) {
return this.get('source');
}
return this.OUR_NUMBER;
},
getContact() {
let conversationId = this.get('source');
if (!this.isIncoming()) {
conversationId = textsecure.storage.user.getNumber();
}
return ConversationController.getOrCreate(conversationId, 'private');
},
getModelForKeyChange() {
const id = this.get('key_changed');
if (!this.modelForKeyChange) {
const c = ConversationController.getOrCreate(id, 'private');
this.modelForKeyChange = c;
}
return this.modelForKeyChange;
},
getModelForVerifiedChange() {
const id = this.get('verifiedChanged');
if (!this.modelForVerifiedChange) {
const c = ConversationController.getOrCreate(id, 'private');
this.modelForVerifiedChange = c;
}
return this.modelForVerifiedChange;
return ConversationController.getOrCreate(this.getSource(), 'private');
},
isOutgoing() {
return this.get('type') === 'outgoing';

View File

@ -4,7 +4,6 @@ const Backbone = require('../../ts/backbone');
const Crypto = require('./crypto');
const Database = require('./database');
const Emoji = require('../../ts/util/emoji');
const Message = require('./types/message');
const Notifications = require('../../ts/notifications');
const OS = require('../../ts/OS');
const Settings = require('./settings');
@ -18,19 +17,38 @@ const {
const { ContactListItem } = require('../../ts/components/ContactListItem');
const { ContactName } = require('../../ts/components/conversation/ContactName');
const {
ConversationTitle,
} = require('../../ts/components/conversation/ConversationTitle');
ConversationHeader,
} = require('../../ts/components/conversation/ConversationHeader');
const {
EmbeddedContact,
} = require('../../ts/components/conversation/EmbeddedContact');
const { Emojify } = require('../../ts/components/conversation/Emojify');
const {
GroupNotification,
} = require('../../ts/components/conversation/GroupNotification');
const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
const { Message } = require('../../ts/components/conversation/Message');
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
const {
MessageDetail,
} = require('../../ts/components/conversation/MessageDetail');
const { Quote } = require('../../ts/components/conversation/Quote');
const {
ResetSessionNotification,
} = require('../../ts/components/conversation/ResetSessionNotification');
const {
SafetyNumberNotification,
} = require('../../ts/components/conversation/SafetyNumberNotification');
const {
TimerNotification,
} = require('../../ts/components/conversation/TimerNotification');
const {
VerificationNotification,
} = require('../../ts/components/conversation/VerificationNotification');
// Migrations
const {
@ -42,11 +60,14 @@ const Migrations1DatabaseWithoutAttachmentData = require('./migrations/migration
// Types
const AttachmentType = require('./types/attachment');
const VisualAttachment = require('./types/visual_attachment');
const Contact = require('../../ts/types/Contact');
const Conversation = require('../../ts/types/Conversation');
const Errors = require('./types/errors');
const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message');
const MessageType = require('./types/message');
const MIME = require('../../ts/types/MIME');
const PhoneNumber = require('../../ts/types/PhoneNumber');
const SettingsType = require('../../ts/types/Settings');
// Views
@ -57,39 +78,59 @@ const { IdleDetector } = require('./idle_detector');
const MessageDataMigrator = require('./messages_data_migrator');
function initializeMigrations({
Attachments,
userDataPath,
Type,
getRegionCode,
Attachments,
Type,
VisualType,
}) {
if (!Attachments) {
return null;
}
const {
getPath,
createReader,
createAbsolutePathGetter,
createWriterForNew,
createWriterForExisting,
} = Attachments;
const {
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
} = VisualType;
const attachmentsPath = Attachments.getPath(userDataPath);
const readAttachmentData = Attachments.createReader(attachmentsPath);
const attachmentsPath = getPath(userDataPath);
const readAttachmentData = createReader(attachmentsPath);
const loadAttachmentData = Type.loadData(readAttachmentData);
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
return {
attachmentsPath,
deleteAttachmentData: Type.deleteData(
Attachments.createDeleter(attachmentsPath)
),
getAbsoluteAttachmentPath: Attachments.createAbsolutePathGetter(
attachmentsPath
),
getAbsoluteAttachmentPath,
getPlaceholderMigrations,
loadAttachmentData,
loadMessage: Message.createAttachmentLoader(loadAttachmentData),
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
Migrations0DatabaseWithAttachmentData,
Migrations1DatabaseWithoutAttachmentData,
upgradeMessageSchema: message =>
Message.upgradeSchema(message, {
writeNewAttachmentData: Attachments.createWriterForNew(attachmentsPath),
MessageType.upgradeSchema(message, {
writeNewAttachmentData: createWriterForNew(attachmentsPath),
getRegionCode,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
}),
writeMessageAttachments: Message.createAttachmentDataWriter(
Attachments.createWriterForExisting(attachmentsPath)
writeMessageAttachments: MessageType.createAttachmentDataWriter(
createWriterForExisting(attachmentsPath)
),
};
}
@ -98,27 +139,35 @@ exports.setup = (options = {}) => {
const { Attachments, userDataPath, getRegionCode } = options;
const Migrations = initializeMigrations({
Attachments,
userDataPath,
Type: AttachmentType,
getRegionCode,
Attachments,
Type: AttachmentType,
VisualType: VisualAttachment,
});
const Components = {
ContactDetail,
ContactListItem,
ContactName,
ConversationTitle,
ConversationHeader,
EmbeddedContact,
Emojify,
GroupNotification,
Lightbox,
LightboxGallery,
MediaGallery,
Message,
MessageBody,
MessageDetail,
Quote,
ResetSessionNotification,
SafetyNumberNotification,
TimerNotification,
Types: {
Message: MediaGalleryMessage,
},
Quote,
VerificationNotification,
};
const Types = {
@ -126,9 +175,11 @@ exports.setup = (options = {}) => {
Contact,
Conversation,
Errors,
Message,
Message: MessageType,
MIME,
PhoneNumber,
Settings: SettingsType,
VisualAttachment,
};
const Views = {

View File

@ -1,7 +1,9 @@
const is = require('@sindresorhus/is');
const AttachmentTS = require('../../../ts/types/Attachment');
const GoogleChrome = require('../../../ts/util/GoogleChrome');
const MIME = require('../../../ts/types/MIME');
const { toLogFormat } = require('./errors');
const {
arrayBufferToBlob,
blobToArrayBuffer,
@ -181,3 +183,112 @@ exports.deleteData = deleteAttachmentData => {
exports.isVoiceMessage = AttachmentTS.isVoiceMessage;
exports.save = AttachmentTS.save;
const THUMBNAIL_SIZE = 150;
const THUMBNAIL_CONTENT_TYPE = 'image/png';
exports.captureDimensionsAndScreenshot = async (
attachment,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
}
) => {
const { contentType } = attachment;
if (
!GoogleChrome.isImageTypeSupported(contentType) &&
!GoogleChrome.isVideoTypeSupported(contentType)
) {
return attachment;
}
const absolutePath = await getAbsoluteAttachmentPath(attachment.path);
if (GoogleChrome.isImageTypeSupported(contentType)) {
try {
const { width, height } = await getImageDimensions(absolutePath);
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail(
THUMBNAIL_SIZE,
absolutePath,
THUMBNAIL_CONTENT_TYPE
)
);
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
return {
...attachment,
width,
height,
thumbnail: {
path: thumbnailPath,
contentType: THUMBNAIL_CONTENT_TYPE,
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
},
};
} catch (error) {
console.log(
'captureDimensionsAndScreenshot:',
'error processing image; skipping screenshot generation',
toLogFormat(error)
);
return attachment;
}
}
let screenshotObjectUrl;
try {
const screenshotBuffer = await blobToArrayBuffer(
await makeVideoScreenshot(absolutePath, THUMBNAIL_CONTENT_TYPE)
);
screenshotObjectUrl = makeObjectUrl(
screenshotBuffer,
THUMBNAIL_CONTENT_TYPE
);
const { width, height } = await getImageDimensions(screenshotObjectUrl);
const screenshotPath = await writeNewAttachmentData(screenshotBuffer);
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail(
THUMBNAIL_SIZE,
screenshotObjectUrl,
THUMBNAIL_CONTENT_TYPE
)
);
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
return {
...attachment,
screenshot: {
contentType: THUMBNAIL_CONTENT_TYPE,
path: screenshotPath,
width,
height,
},
thumbnail: {
path: thumbnailPath,
contentType: THUMBNAIL_CONTENT_TYPE,
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
},
width,
height,
};
} catch (error) {
console.log(
'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation',
toLogFormat(error)
);
return attachment;
} finally {
revokeObjectUrl(screenshotObjectUrl);
}
};

View File

@ -41,6 +41,9 @@ const PRIVATE = 'private';
// - `hasVisualMediaAttachments`: Include all images and video regardless of
// whether Chromium can render it or not.
// - `hasFileAttachments`: Exclude voice messages.
// Version 8
// - Attachments: Capture video/image dimensions and thumbnails, as well as a
// full-size screenshot for video.
const INITIAL_SCHEMA_VERSION = 0;
@ -128,7 +131,7 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => {
upgradedMessage = await upgrade(message, context);
} catch (error) {
console.log(
'Message._withSchemaVersion: error:',
`Message._withSchemaVersion: error updating message ${message.id}:`,
Errors.toLogFormat(error)
);
return message;
@ -242,6 +245,11 @@ const toVersion6 = exports._withSchemaVersion(
// classified:
const toVersion7 = exports._withSchemaVersion(7, initializeAttachmentMetadata);
const toVersion8 = exports._withSchemaVersion(
8,
exports._mapAttachments(Attachment.captureDimensionsAndScreenshot)
);
const VERSIONS = [
toVersion0,
toVersion1,
@ -251,19 +259,47 @@ const VERSIONS = [
toVersion5,
toVersion6,
toVersion7,
toVersion8,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// UpgradeStep
exports.upgradeSchema = async (
rawMessage,
{ writeNewAttachmentData, getRegionCode } = {}
{
writeNewAttachmentData,
getRegionCode,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
} = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError('`context.writeNewAttachmentData` is required');
throw new TypeError('context.writeNewAttachmentData is required');
}
if (!isFunction(getRegionCode)) {
throw new TypeError('`context.getRegionCode` is required');
throw new TypeError('context.getRegionCode is required');
}
if (!isFunction(getAbsoluteAttachmentPath)) {
throw new TypeError('context.getAbsoluteAttachmentPath is required');
}
if (!isFunction(makeObjectUrl)) {
throw new TypeError('context.makeObjectUrl is required');
}
if (!isFunction(revokeObjectUrl)) {
throw new TypeError('context.revokeObjectUrl is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isFunction(makeImageThumbnail)) {
throw new TypeError('context.makeImageThumbnail is required');
}
if (!isFunction(makeVideoScreenshot)) {
throw new TypeError('context.makeVideoScreenshot is required');
}
let message = rawMessage;
@ -275,6 +311,12 @@ exports.upgradeSchema = async (
message = await currentVersion(message, {
writeNewAttachmentData,
regionCode: getRegionCode(),
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
});
}

View File

@ -0,0 +1,126 @@
/* global document, URL, Blob */
const loadImage = require('blueimp-load-image');
const { toLogFormat } = require('./errors');
const dataURLToBlobSync = require('blueimp-canvas-to-blob');
const { blobToArrayBuffer } = require('blob-util');
const {
arrayBufferToObjectURL,
} = require('../../../ts/util/arrayBufferToObjectURL');
exports.blobToArrayBuffer = blobToArrayBuffer;
exports.getImageDimensions = objectUrl =>
new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
resolve({
height: image.naturalHeight,
width: image.naturalWidth,
});
});
image.addEventListener('error', error => {
console.log('getImageDimensions error', toLogFormat(error));
reject(error);
});
image.src = objectUrl;
});
exports.makeImageThumbnail = (size, objectUrl, contentType = 'image/png') =>
new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
// using components/blueimp-load-image
// first, make the correct size
let canvas = loadImage.scale(image, {
canvas: true,
cover: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
// then crop
canvas = loadImage.scale(canvas, {
canvas: true,
crop: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
const blob = dataURLToBlobSync(canvas.toDataURL(contentType));
resolve(blob);
});
image.addEventListener('error', error => {
console.log('makeImageThumbnail error', toLogFormat(error));
reject(error);
});
image.src = objectUrl;
});
exports.makeVideoScreenshot = (objectUrl, contentType = 'image/png') =>
new Promise((resolve, reject) => {
const video = document.createElement('video');
function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas
.getContext('2d')
.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = dataURLToBlobSync(canvas.toDataURL(contentType));
video.removeEventListener('canplay', capture);
resolve(image);
}
video.addEventListener('canplay', capture);
video.addEventListener('error', error => {
console.log('makeVideoThumbnail error', toLogFormat(error));
reject(error);
});
video.src = objectUrl;
});
exports.makeVideoThumbnail = async (size, videoObjectUrl) => {
let screenshotObjectUrl;
try {
const type = 'image/png';
const blob = await exports.makeVideoScreenshot(videoObjectUrl, type);
const data = await blobToArrayBuffer(blob);
screenshotObjectUrl = arrayBufferToObjectURL({
data,
type,
});
return exports.makeImageThumbnail(size, screenshotObjectUrl);
} finally {
exports.revokeObjectUrl(screenshotObjectUrl);
}
};
exports.makeObjectUrl = (data, contentType) => {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
};
exports.revokeObjectUrl = objectUrl => {
URL.revokeObjectURL(objectUrl);
};

View File

@ -13,9 +13,6 @@
tagName: 'div',
className: 'contact',
templateName: 'contact',
events: {
click: 'showIdentity',
},
initialize(options) {
this.ourNumber = textsecure.storage.user.getNumber();
this.listenBack = options.listenBack;

View File

@ -1,4 +1,4 @@
/* global Whisper, getInboxCollection */
/* global Whisper, getInboxCollection, $ */
// eslint-disable-next-line func-names
(function() {

View File

@ -2,7 +2,6 @@
/* global _: false */
/* global emojiData: false */
/* global EmojiPanel: false */
/* global moment: false */
/* global extension: false */
/* global i18n: false */
/* global Signal: false */
@ -14,6 +13,7 @@
'use strict';
window.Whisper = window.Whisper || {};
const { Migrations } = Signal;
Whisper.ExpiredToast = Whisper.ToastView.extend({
render_attributes() {
@ -31,42 +31,6 @@
},
});
const MenuView = Whisper.View.extend({
toggleMenu() {
this.$('.menu-list').toggle();
},
});
const TimerMenuView = MenuView.extend({
initialize() {
this.render();
this.listenTo(this.model, 'change:expireTimer', this.render);
},
events: {
'click button': 'toggleMenu',
'click li': 'setTimer',
},
setTimer(e) {
const { seconds } = this.$(e.target).data();
if (seconds > 0) {
this.model.updateExpirationTimer(seconds);
} else {
this.model.updateExpirationTimer(null);
}
},
render() {
const seconds = this.model.get('expireTimer');
if (seconds) {
const s = Whisper.ExpirationTimerOptions.getAbbreviated(seconds);
this.$el.attr('data-time', s);
this.$el.show();
} else {
this.$el.attr('data-time', null);
this.$el.hide();
}
},
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen',
className: 'conversation-loading-screen',
@ -82,35 +46,23 @@
template: $('#conversation').html(),
render_attributes() {
return {
group: this.model.get('type') === 'group',
isMe: this.model.isMe(),
avatar: this.model.getAvatar(),
expireTimer: this.model.get('expireTimer'),
'show-members': i18n('showMembers'),
'end-session': i18n('resetSession'),
'show-identity': i18n('showSafetyNumber'),
destroy: i18n('deleteMessages'),
'send-message': i18n('sendMessage'),
'disappearing-messages': i18n('disappearingMessages'),
'android-length-warning': i18n('androidMessageLengthWarning'),
timer_options: Whisper.ExpirationTimerOptions.models,
'view-all-media': i18n('viewAllMedia'),
};
},
initialize(options) {
this.listenTo(this.model, 'destroy', this.stopListening);
this.listenTo(this.model, 'change:verified', this.onVerifiedChange);
this.listenTo(this.model, 'change:color', this.updateColor);
this.listenTo(
this.model,
'change:avatar change:profileAvatar',
this.updateAvatar
);
this.listenTo(this.model, 'newmessage', this.addMessage);
this.listenTo(this.model, 'delivered', this.updateMessage);
this.listenTo(this.model, 'read', this.updateMessage);
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'prune', this.onPrune);
this.listenTo(
this.model.messageCollection,
'show-identity',
this.showSafetyNumber
);
this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
this.listenTo(
this.model.messageCollection,
'scroll-to-message',
@ -126,11 +78,26 @@
'show-contact-detail',
this.showContactDetail
);
this.listenTo(
this.model.messageCollection,
'show-lightbox',
this.showLightbox
);
this.listenTo(
this.model.messageCollection,
'download',
this.downloadAttachment
);
this.listenTo(
this.model.messageCollection,
'open-conversation',
this.openConversation
);
this.listenTo(
this.model.messageCollection,
'show-message-detail',
this.showMessageDetail
);
this.lazyUpdateVerified = _.debounce(
this.model.updateVerified.bind(this.model),
@ -145,12 +112,7 @@
this.loadingScreen = new Whisper.ConversationLoadingScreen();
this.loadingScreen.render();
this.loadingScreen.$el.prependTo(this.el);
this.timerMenu = new TimerMenuView({
el: this.$('.timer-menu'),
model: this.model,
});
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
this.window = options.window;
this.fileInput = new Whisper.FileInputView({
@ -158,21 +120,64 @@
window: this.window,
});
const getTitleProps = model => ({
isVerified: model.isVerified(),
name: model.getName(),
phoneNumber: model.getNumber(),
profileName: model.getProfileName(),
});
const getHeaderProps = () => {
const avatar = this.model.getAvatar();
const avatarPath = avatar ? avatar.url : null;
const expireTimer = this.model.get('expireTimer');
const expirationSettingName = expireTimer
? Whisper.ExpirationTimerOptions.getName(expireTimer || 0)
: null;
return {
id: this.model.id,
name: this.model.getName(),
phoneNumber: this.model.getNumber(),
profileName: this.model.getProfileName(),
color: this.model.getColor(),
avatarPath,
isVerified: this.model.isVerified(),
isMe: this.model.isMe(),
isGroup: !this.model.isPrivate(),
expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length),
timerOptions: Whisper.ExpirationTimerOptions.map(item => ({
name: item.getName(),
value: item.get('seconds'),
})),
onSetDisappearingMessages: seconds =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
// These are view only and done update the Conversation model, so they
// need a manual update call.
onShowSafetyNumber: () => {
this.showSafetyNumber();
this.updateHeader();
},
onShowAllMedia: async () => {
await this.showAllMedia();
this.updateHeader();
},
onShowGroupMembers: () => {
this.showMembers();
this.updateHeader();
},
onGoBack: () => {
this.resetPanel();
this.updateHeader();
},
};
};
this.titleView = new Whisper.ReactWrapperView({
className: 'title-wrapper',
Component: window.Signal.Components.ConversationTitle,
props: getTitleProps(this.model),
Component: window.Signal.Components.ConversationHeader,
props: getHeaderProps(this.model),
});
this.listenTo(this.model, 'change', () =>
this.titleView.update(getTitleProps(this.model))
);
this.$('.conversation-title').prepend(this.titleView.el);
this.updateHeader = () => this.titleView.update(getHeaderProps());
this.listenTo(this.model, 'change', this.updateHeader);
this.$('.conversation-header').append(this.titleView.el);
this.view = new Whisper.MessageListView({
collection: this.model.messageCollection,
@ -210,20 +215,10 @@
'submit .send': 'checkUnverifiedSendMessage',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'click .destroy': 'destroyMessages',
'click .end-session': 'endSession',
'click .leave-group': 'leaveGroup',
'click .update-group': 'newGroupUpdate',
'click .show-identity': 'showSafetyNumber',
'click .show-members': 'showMembers',
'click .view-all-media': 'viewAllMedia',
'click .conversation-menu .hamburger': 'toggleMenu',
click: 'onClick',
'click .bottom-bar': 'focusMessageField',
'click .back': 'resetPanel',
'click .capture-audio .microphone': 'captureAudio',
'click .disappearing-messages': 'enableDisappearingMessages',
'click .scroll-down-button-view': 'scrollToBottom',
'click .module-scroll-down': 'scrollToBottom',
'click button.emoji': 'toggleEmojiPanel',
'focus .send-message': 'focusBottomBar',
'change .file-input': 'toggleMicrophone',
@ -233,10 +228,7 @@
'atBottom .message-list': 'removeScrollDownButton',
'farFromBottom .message-list': 'addScrollDownButton',
'lazyScroll .message-list': 'onLazyScroll',
'close .menu': 'closeMenu',
'select .message-list .entry': 'messageDetail',
'force-resize': 'forceUpdateMessageFieldSize',
'show-identity': 'showSafetyNumber',
dragover: 'sendToFileInput',
drop: 'sendToFileInput',
dragleave: 'sendToFileInput',
@ -269,7 +261,6 @@
reason
);
this.timerMenu.remove();
this.fileInput.remove();
this.titleView.remove();
@ -288,6 +279,9 @@
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightBoxView) {
this.lightBoxView.remove();
}
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
}
@ -362,7 +356,7 @@
openSafetyNumberScreens(unverified) {
if (unverified.length === 1) {
this.showSafetyNumber(null, unverified.at(0));
this.showSafetyNumber(unverified.at(0));
return;
}
@ -406,11 +400,6 @@
}
},
enableDisappearingMessages() {
if (!this.model.get('expireTimer')) {
this.model.updateExpirationTimer(moment.duration(1, 'day').asSeconds());
}
},
toggleMicrophone() {
if (
this.$('.send-message').val().length > 0 ||
@ -591,11 +580,7 @@
el[0].scrollIntoView();
},
async viewAllMedia() {
// We have to do this manually, since our React component will not propagate click
// events up to its parent elements in the DOM.
this.closeMenu();
async showAllMedia() {
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
@ -620,7 +605,7 @@
// NOTE: Could we show grid previews from disk as well?
const loadMessages = Signal.Components.Types.Message.loadWithObjectURL(
Signal.Migrations.loadMessage
Migrations.loadMessage
);
const media = await loadMessages(rawMedia);
@ -655,6 +640,7 @@
mediaMessage => mediaMessage.id === message.id
);
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.LightboxGallery,
props: {
messages: mediaWithObjectURL,
@ -673,6 +659,7 @@
};
const view = new Whisper.ReactWrapperView({
className: 'panel-wrapper',
Component: Signal.Components.MediaGallery,
props: {
documents,
@ -840,14 +827,10 @@
}
}
},
updateMessage(message) {
this.model.messageCollection.add(message, { merge: true });
},
onClick(e) {
onClick() {
// If there are sub-panels open, we don't want to respond to clicks
if (!this.panels || !this.panels.length) {
this.closeMenu(e);
this.markRead();
}
},
@ -930,7 +913,31 @@
this.listenBack(view);
},
showSafetyNumber(e, providedModel) {
forceSend({ contact, message }) {
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('identityKeyErrorOnSend'),
okText: i18n('sendAnyway'),
resolve: async () => {
await contact.updateVerified();
if (contact.isUnverified()) {
await contact.setVerifiedDefault();
}
const untrusted = await contact.isUntrusted();
if (untrusted) {
await contact.setApproved();
}
message.resend(contact.id);
},
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
showSafetyNumber(providedModel) {
let model = providedModel;
if (!model && this.model.isPrivate()) {
@ -945,26 +952,78 @@
}
},
messageDetail(e, data) {
const view = new Whisper.MessageDetailView({
model: data.message,
conversation: this.model,
// we pass these in to allow nested panels
listenBack: this.listenBack.bind(this),
resetPanel: this.resetPanel.bind(this),
downloadAttachment({ attachment, message }) {
const { getAbsoluteAttachmentPath } = Migrations;
Signal.Types.Attachment.save({
attachment,
document,
getAbsolutePath: getAbsoluteAttachmentPath,
timestamp: message.get('sent_at'),
});
this.listenBack(view);
view.render();
},
// not currently in use
newGroupUpdate() {
const view = new Whisper.NewGroupUpdateView({
model: this.model,
window: this.window,
deleteMessage(message) {
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('deleteWarning'),
okText: i18n('delete'),
resolve: () => {
message.destroy();
this.resetPanel();
this.updateHeader();
},
});
view.render();
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
showLightbox({ attachment, message }) {
const { getAbsoluteAttachmentPath } = Migrations;
const { contentType, path } = attachment;
if (
!Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
) {
this.downloadAttachment({ attachment, message });
return;
}
const props = {
objectURL: getAbsoluteAttachmentPath(path),
contentType,
onSave: () => this.downloadAttachment({ attachment, message }),
};
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.Lightbox,
props,
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
showMessageDetail(message) {
const props = message.getPropsForMessageDetail();
const view = new Whisper.ReactWrapperView({
className: 'message-detail-wrapper',
Component: Signal.Components.MessageDetail,
props,
onClose: () => {
this.stopListening(message, 'change', update);
this.resetPanel();
this.updateHeader();
},
});
const update = () => view.update(message.getPropsForMessageDetail());
this.listenTo(message, 'change', update);
// We could listen to all involved contacts, but we'll call that overkill
this.listenBack(view);
this.updateHeader();
view.render();
},
showContactDetail({ contact, hasSignalAccount }) {
@ -989,7 +1048,10 @@
}
},
},
onClose: () => this.resetPanel(),
onClose: () => {
this.resetPanel();
this.updateHeader();
},
});
this.listenBack(view);
@ -1009,57 +1071,45 @@
this.panels[0].$el.hide();
}
this.panels.unshift(view);
if (this.panels.length === 1) {
this.$('.main.panel, .header-buttons.right').hide();
this.$('.back').show();
}
view.$el.insertBefore(this.$('.panel').first());
},
resetPanel() {
if (!this.panels || !this.panels.length) {
return;
}
const view = this.panels.shift();
if (this.panels.length > 0) {
this.panels[0].$el.show();
}
view.remove();
if (this.panels.length === 0) {
this.$('.main.panel, .header-buttons.right').show();
this.$('.back').hide();
this.$el.trigger('force-resize');
}
},
closeMenu(e) {
if (e && !$(e.target).hasClass('hamburger')) {
this.$('.conversation-menu .menu-list').hide();
}
if (e && !$(e.target).hasClass('clock')) {
this.$('.timer-menu .menu-list').hide();
}
},
endSession() {
this.model.endSession();
this.$('.menu-list').hide();
},
leaveGroup() {
this.model.leaveGroup();
this.$('.menu-list').hide();
},
toggleMenu() {
this.$('.conversation-menu .menu-list').toggle();
setDisappearingMessages(seconds) {
if (seconds > 0) {
this.model.updateExpirationTimer(seconds);
} else {
this.model.updateExpirationTimer(null);
}
},
async destroyMessages() {
this.$('.menu-list').hide();
await this.confirm(i18n('deleteConversationConfirmation'));
this.model.destroyMessages();
this.remove();
try {
await this.confirm(i18n('deleteConversationConfirmation'));
await this.model.destroyMessages();
this.remove();
} catch (error) {
// nothing to see here
}
},
showSendConfirmationDialog(e, contacts) {
@ -1247,24 +1297,21 @@
const contact = this.quotedMessage.getContact();
if (contact) {
this.listenTo(contact, 'change:color', this.renderQuotedMesage);
this.listenTo(contact, 'change', this.renderQuotedMesage);
}
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
props: Object.assign({}, props, {
text: props.text,
withContentAbove: true,
onClose: () => {
this.setQuoteMessage(null);
},
}),
});
const selector =
storage.get('theme-setting') === 'ios' ? '.bottom-bar' : '.send';
this.$(selector).prepend(this.quoteView.el);
this.$('.send').prepend(this.quoteView.el);
this.updateMessageFieldSize({});
},
@ -1319,28 +1366,6 @@
}
},
updateColor(model, color) {
const header = this.$('.conversation-header');
header.removeClass(Whisper.Conversation.COLORS);
if (color) {
header.addClass(color);
}
const avatarView = new (Whisper.View.extend({
templateName: 'avatar',
render_attributes: { avatar: this.model.getAvatar() },
}))();
header.find('.avatar').replaceWith(avatarView.render().$('.avatar'));
},
updateAvatar() {
const header = this.$('.conversation-header');
const avatarView = new (Whisper.View.extend({
templateName: 'avatar',
render_attributes: { avatar: this.model.getAvatar() },
}))();
header.find('.avatar').replaceWith(avatarView.render().$('.avatar'));
},
updateMessageFieldSize(event) {
const keyCode = event.which || event.keyCode;

View File

@ -12,7 +12,7 @@
window.Whisper = window.Whisper || {};
const { MIME } = window.Signal.Types;
const { MIME, VisualAttachment } = window.Signal.Types;
Whisper.FileSizeToast = Whisper.ToastView.extend({
templateName: 'file-size-modal',
@ -28,98 +28,6 @@
template: i18n('unsupportedFileType'),
});
function makeImageThumbnail(size, objectUrl) {
return new Promise((resolve, reject) => {
const img = document.createElement('img');
img.onerror = reject;
img.onload = () => {
// using components/blueimp-load-image
// first, make the correct size
let canvas = loadImage.scale(img, {
canvas: true,
cover: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
// then crop
canvas = loadImage.scale(canvas, {
canvas: true,
crop: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
const blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
resolve(blob);
};
img.src = objectUrl;
});
}
function makeVideoScreenshot(objectUrl) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas
.getContext('2d')
.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
video.removeEventListener('canplay', capture);
resolve(image);
}
video.addEventListener('canplay', capture);
video.addEventListener('error', error => {
console.log(
'makeVideoThumbnail error',
Signal.Types.Errors.toLogFormat(error)
);
reject(error);
});
video.src = objectUrl;
});
}
function blobToArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = e => resolve(e.target.result);
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(blob);
});
}
async function makeVideoThumbnail(size, videoObjectUrl) {
const blob = await makeVideoScreenshot(videoObjectUrl);
const data = await blobToArrayBuffer(blob);
const screenshotObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type: 'image/png',
});
const thumbnail = await makeImageThumbnail(size, screenshotObjectUrl);
URL.revokeObjectURL(screenshotObjectUrl);
return thumbnail;
}
Whisper.FileInputView = Backbone.View.extend({
tagName: 'span',
className: 'file-input',
@ -252,10 +160,14 @@
const renderVideoPreview = async () => {
// we use the variable on this here to ensure cleanup if we're interrupted
this.previewObjectUrl = URL.createObjectURL(file);
const thumbnail = await makeVideoScreenshot(this.previewObjectUrl);
const type = 'image/png';
const thumbnail = await VisualAttachment.makeVideoScreenshot(
this.previewObjectUrl,
type
);
URL.revokeObjectURL(this.previewObjectUrl);
const data = await blobToArrayBuffer(thumbnail);
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type: 'image/png',
@ -385,7 +297,10 @@
const objectUrl = URL.createObjectURL(file);
const arrayBuffer = await makeImageThumbnail(size, objectUrl);
const arrayBuffer = await VisualAttachment.makeImageThumbnail(
size,
objectUrl
);
URL.revokeObjectURL(objectUrl);
return this.readFile(arrayBuffer);
@ -482,8 +397,4 @@
}
},
});
Whisper.FileInputView.makeImageThumbnail = makeImageThumbnail;
Whisper.FileInputView.makeVideoThumbnail = makeVideoThumbnail;
Whisper.FileInputView.makeVideoScreenshot = makeVideoScreenshot;
})();

View File

@ -1,55 +0,0 @@
/* global Whisper, i18n */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.IdentityKeySendErrorPanelView = Whisper.View.extend({
className: 'identity-key-send-error panel',
templateName: 'identity-key-send-error',
initialize(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.wasUnverified = this.model.isUnverified();
this.listenTo(this.model, 'change', this.render);
},
events: {
'click .show-safety-number': 'showSafetyNumber',
'click .send-anyway': 'sendAnyway',
'click .cancel': 'cancel',
},
showSafetyNumber() {
const view = new Whisper.KeyVerificationPanelView({
model: this.model,
});
this.listenBack(view);
},
sendAnyway() {
this.resetPanel();
this.trigger('send-anyway');
},
cancel() {
this.resetPanel();
},
render_attributes() {
let send = i18n('sendAnyway');
if (this.wasUnverified && !this.model.isUnverified()) {
send = i18n('resend');
}
const errorExplanation = i18n('identityKeyErrorOnSend', [
this.model.getTitle(),
this.model.getTitle(),
]);
return {
errorExplanation,
showSafetyNumber: i18n('showSafetyNumber'),
sendAnyway: send,
cancel: i18n('cancel'),
};
},
});
})();

View File

@ -108,7 +108,9 @@
const inboxCollection = getInboxCollection();
inboxCollection.on('messageError', () => {
this.networkStatusView.render();
if (this.networkStatusView) {
this.networkStatusView.render();
}
});
this.inboxListView = new Whisper.ConversationListView({

View File

@ -7,7 +7,7 @@
window.Whisper = window.Whisper || {};
Whisper.LastSeenIndicatorView = Whisper.View.extend({
className: 'last-seen-indicator-view',
className: 'module-last-seen-indicator',
templateName: 'last-seen-indicator-view',
initialize(options = {}) {
this.count = options.count || 0;

View File

@ -1,182 +0,0 @@
/* global Whisper, i18n, _, ConversationController, Mustache, moment */
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const ContactView = Whisper.View.extend({
className: 'contact-detail',
templateName: 'contact-detail',
initialize(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.message = options.message;
const newIdentity = i18n('newIdentity');
this.errors = _.map(options.errors, error => {
if (error.name === 'OutgoingIdentityKeyError') {
// eslint-disable-next-line no-param-reassign
error.message = newIdentity;
}
return error;
});
this.outgoingKeyError = _.find(
this.errors,
error => error.name === 'OutgoingIdentityKeyError'
);
},
events: {
click: 'onClick',
},
onClick() {
if (this.outgoingKeyError) {
const view = new Whisper.IdentityKeySendErrorPanelView({
model: this.model,
listenBack: this.listenBack,
resetPanel: this.resetPanel,
});
this.listenTo(view, 'send-anyway', this.onSendAnyway);
view.render();
this.listenBack(view);
view.$('.cancel').focus();
}
},
forceSend() {
this.model
.updateVerified()
.then(() => {
if (this.model.isUnverified()) {
return this.model.setVerifiedDefault();
}
return null;
})
.then(() => this.model.isUntrusted())
.then(untrusted => {
if (untrusted) {
return this.model.setApproved();
}
return null;
})
.then(() => {
this.message.resend(this.outgoingKeyError.number);
});
},
onSendAnyway() {
if (this.outgoingKeyError) {
this.forceSend();
}
},
render_attributes() {
const showButton = Boolean(this.outgoingKeyError);
return {
status: this.message.getStatus(this.model.id),
name: this.model.getTitle(),
avatar: this.model.getAvatar(),
errors: this.errors,
showErrorButton: showButton,
errorButtonLabel: i18n('view'),
};
},
});
Whisper.MessageDetailView = Whisper.View.extend({
className: 'message-detail panel',
templateName: 'message-detail',
initialize(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.view = new Whisper.MessageView({ model: this.model });
this.view.render();
this.conversation = options.conversation;
this.listenTo(this.model, 'change', this.render);
},
events: {
'click button.delete': 'onDelete',
},
onDelete() {
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('deleteWarning'),
okText: i18n('delete'),
resolve: () => {
this.model.destroy();
this.resetPanel();
},
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
getContacts() {
// Return the set of models to be rendered in this view
let ids;
if (this.model.isIncoming()) {
ids = [this.model.get('source')];
} else if (this.model.isOutgoing()) {
ids = this.model.get('recipients');
if (!ids) {
// older messages have no recipients field
// use the current set of recipients
ids = this.conversation.getRecipients();
}
}
return Promise.all(
ids.map(number =>
ConversationController.getOrCreateAndWait(number, 'private')
)
);
},
renderContact(contact) {
const view = new ContactView({
model: contact,
errors: this.grouped[contact.id],
listenBack: this.listenBack,
resetPanel: this.resetPanel,
message: this.model,
}).render();
this.$('.contacts').append(view.el);
},
render() {
const errorsWithoutNumber = _.reject(this.model.get('errors'), error =>
Boolean(error.number)
);
this.$el.html(
Mustache.render(_.result(this, 'template', ''), {
sent_at: moment(this.model.get('sent_at')).format('LLLL'),
received_at: this.model.isIncoming()
? moment(this.model.get('received_at')).format('LLLL')
: null,
tofrom: this.model.isIncoming() ? i18n('from') : i18n('to'),
errors: errorsWithoutNumber,
title: i18n('messageDetail'),
sent: i18n('sent'),
received: i18n('received'),
errorLabel: i18n('error'),
deleteLabel: i18n('deleteMessage'),
})
);
this.view.$el.prependTo(this.$('.message-container'));
this.grouped = _.groupBy(this.model.get('errors'), 'number');
this.getContacts().then(contacts => {
_.sortBy(contacts, c => {
const prefix = this.grouped[c.id] ? '0' : '1';
// this prefix ensures that contacts with errors are listed first;
// otherwise it's alphabetical
return prefix + c.getTitle();
}).forEach(this.renderContact.bind(this));
});
},
});
})();

View File

@ -64,19 +64,10 @@
this.measureScrollPosition();
},
addOne(model) {
let view;
if (model.isExpirationTimerUpdate()) {
view = new Whisper.ExpirationTimerUpdateView({ model }).render();
} else if (model.get('type') === 'keychange') {
view = new Whisper.KeyChangeView({ model }).render();
} else if (model.get('type') === 'verified-change') {
view = new Whisper.VerifiedChangeView({ model }).render();
} else {
// eslint-disable-next-line new-cap
view = new this.itemView({ model }).render();
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
}
// eslint-disable-next-line new-cap
const view = new this.itemView({ model }).render();
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
const index = this.collection.indexOf(model);
this.measureScrollPosition();

View File

@ -1,743 +1,115 @@
/* global Whisper: false */
/* global i18n: false */
/* global textsecure: false */
/* global _: false */
/* global Mustache: false */
/* global $: false */
/* global storage: false */
/* global Signal: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
const {
loadAttachmentData,
getAbsoluteAttachmentPath,
} = window.Signal.Migrations;
window.Whisper = window.Whisper || {};
const ErrorIconView = Whisper.View.extend({
templateName: 'error-icon',
className: 'error-icon-container',
initialize() {
if (this.model.name === 'UnregisteredUserError') {
this.$el.addClass('unregistered-user-error');
}
},
});
const NetworkErrorView = Whisper.View.extend({
tagName: 'span',
className: 'hasRetry',
templateName: 'hasRetry',
render_attributes() {
let messageNotSent;
if (!this.model.someRecipientsFailed()) {
messageNotSent = i18n('messageNotSent');
}
return {
messageNotSent,
resend: i18n('resend'),
};
},
});
const SomeFailedView = Whisper.View.extend({
tagName: 'span',
className: 'some-failed',
templateName: 'some-failed',
render_attributes() {
return {
someFailed: i18n('someRecipientsFailed'),
};
},
});
const TimerView = Whisper.View.extend({
templateName: 'hourglass',
initialize() {
this.listenTo(this.model, 'unload', this.remove);
},
update() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.model.isExpired()) {
return this;
}
if (this.model.isExpiring()) {
this.render();
const totalTime = this.model.get('expireTimer') * 1000;
const remainingTime = this.model.msTilExpire();
const elapsed = (totalTime - remainingTime) / totalTime;
this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
this.$el.css('display', 'inline-block');
this.timeout = setTimeout(
this.update.bind(this),
Math.max(totalTime / 100, 500)
);
}
return this;
},
});
Whisper.ExpirationTimerUpdateView = Whisper.View.extend({
Whisper.MessageView = Whisper.View.extend({
tagName: 'li',
className: 'expirationTimerUpdate advisory',
templateName: 'expirationTimerUpdate',
id() {
return this.model.id;
},
initialize() {
this.conversation = this.model.getExpirationTimerUpdateSource();
this.listenTo(this.conversation, 'change', this.render);
this.listenTo(this.model, 'unload', this.remove);
this.listenTo(this.model, 'change', this.onChange);
},
render_attributes() {
const seconds = this.model.get('expirationTimerUpdate').expireTimer;
let timerMessage;
const timerUpdate = this.model.get('expirationTimerUpdate');
const prettySeconds = Whisper.ExpirationTimerOptions.getName(
seconds || 0
);
if (
timerUpdate &&
(timerUpdate.fromSync || timerUpdate.fromGroupUpdate)
) {
timerMessage = i18n('timerSetOnSync', prettySeconds);
} else if (this.conversation.id === textsecure.storage.user.getNumber()) {
timerMessage = i18n('youChangedTheTimer', prettySeconds);
} else {
timerMessage = i18n('theyChangedTheTimer', [
this.conversation.getTitle(),
prettySeconds,
]);
}
return { content: timerMessage };
this.listenTo(this.model, 'destroy', this.onDestroy);
this.listenTo(this.model, 'unload', this.onUnload);
},
onChange() {
this.addId();
},
addId() {
// This is important to enable the lastSeenIndicator when it's just been added.
this.$el.attr('id', this.id());
},
});
Whisper.KeyChangeView = Whisper.View.extend({
tagName: 'li',
className: 'keychange advisory',
templateName: 'keychange',
id() {
return this.model.id;
},
initialize() {
this.conversation = this.model.getModelForKeyChange();
this.listenTo(this.conversation, 'change', this.render);
this.listenTo(this.model, 'unload', this.remove);
},
events: {
'click .content': 'showIdentity',
},
render_attributes() {
return {
content: this.model.getNotificationText(),
};
},
showIdentity() {
this.$el.trigger('show-identity', this.conversation);
},
});
Whisper.VerifiedChangeView = Whisper.View.extend({
tagName: 'li',
className: 'verified-change advisory',
templateName: 'verified-change',
id() {
return this.model.id;
},
initialize() {
this.conversation = this.model.getModelForVerifiedChange();
this.listenTo(this.conversation, 'change', this.render);
this.listenTo(this.model, 'unload', this.remove);
},
events: {
'click .content': 'showIdentity',
},
render_attributes() {
let key;
if (this.model.get('verified')) {
if (this.model.get('local')) {
key = 'youMarkedAsVerified';
} else {
key = 'youMarkedAsVerifiedOtherDevice';
}
return {
icon: 'verified',
content: i18n(key, this.conversation.getTitle()),
};
}
if (this.model.get('local')) {
key = 'youMarkedAsNotVerified';
} else {
key = 'youMarkedAsNotVerifiedOtherDevice';
}
return {
icon: 'shield',
content: i18n(key, this.conversation.getTitle()),
};
},
showIdentity() {
this.$el.trigger('show-identity', this.conversation);
},
});
Whisper.MessageView = Whisper.View.extend({
tagName: 'li',
templateName: 'message',
id() {
return this.model.id;
},
initialize() {
// loadedAttachmentViews :: Promise (Array AttachmentView) | null
this.loadedAttachmentViews = null;
this.listenTo(this.model, 'change:errors', this.onErrorsChanged);
this.listenTo(this.model, 'change:body', this.render);
this.listenTo(this.model, 'change:delivered', this.renderDelivered);
this.listenTo(this.model, 'change:read_by', this.renderRead);
this.listenTo(
this.model,
'change:expirationStartTimestamp',
this.renderExpiring
);
this.listenTo(this.model, 'change', this.onChange);
this.listenTo(
this.model,
'change:flags change:group_update',
this.renderControl
);
this.listenTo(this.model, 'destroy', this.onDestroy);
this.listenTo(this.model, 'unload', this.onUnload);
this.listenTo(this.model, 'expired', this.onExpired);
this.listenTo(this.model, 'pending', this.renderPending);
this.listenTo(this.model, 'done', this.renderDone);
this.timeStampView = new Whisper.ExtendedTimestampView();
this.contact = this.model.isIncoming() ? this.model.getContact() : null;
if (this.contact) {
this.listenTo(this.contact, 'change:color', this.updateColor);
}
},
events: {
'click .retry': 'retryMessage',
'click .error-icon': 'select',
'click .timestamp': 'select',
'click .status': 'select',
'click .some-failed': 'select',
'click .error-message': 'select',
'click .menu-container': 'showMenu',
'click .menu-list .reply': 'onReply',
},
retryMessage() {
const retrys = _.filter(
this.model.get('errors'),
this.model.isReplayableError.bind(this.model)
);
_.map(retrys, 'number').forEach(number => {
this.model.resend(number);
});
},
showMenu(e) {
if (this.menuVisible) {
return;
}
this.menuVisible = true;
e.stopPropagation();
this.$('.menu-list').show();
$(document).one('click', () => {
this.hideMenu();
});
},
hideMenu() {
this.menuVisible = false;
this.$('.menu-list').hide();
},
onReply() {
this.model.trigger('reply', this.model);
},
onExpired() {
this.$el.addClass('expired');
this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
if (e.target === this.$('.bubble')[0]) {
this.remove();
}
});
// Failsafe: if in the background, animation events don't fire
setTimeout(this.remove.bind(this), 1000);
// The ID is important for other items inserting themselves into the DOM. Because
// of ReactWrapperView and this view, there are two layers of DOM elements
// between the parent and the elements returned by the React component, so this is
// necessary.
const { id } = this.model;
this.$el.attr('id', id);
},
onUnload() {
if (this.avatarView) {
this.avatarView.remove();
if (this.childView) {
this.childView.remove();
}
if (this.bodyView) {
this.bodyView.remove();
}
if (this.contactView) {
this.contactView.remove();
}
if (this.controlView) {
this.controlView.remove();
}
if (this.errorIconView) {
this.errorIconView.remove();
}
if (this.networkErrorView) {
this.networkErrorView.remove();
}
if (this.quoteView) {
this.quoteView.remove();
}
if (this.someFailedView) {
this.someFailedView.remove();
}
if (this.timeStampView) {
this.timeStampView.remove();
}
// NOTE: We have to do this in the background (`then` instead of `await`)
// as our tests rely on `onUnload` synchronously removing the view from
// the DOM.
// eslint-disable-next-line more/no-then
this.loadAttachmentViews().then(views =>
views.forEach(view => view.unload())
);
// No need to handle this one, since it listens to 'unload' itself:
// this.timerView
this.remove();
},
onDestroy() {
if (this.$el.hasClass('expired')) {
return;
}
this.onUnload();
},
onChange() {
this.renderSent();
this.renderQuote();
this.addId();
},
select(e) {
this.$el.trigger('select', { message: this.model });
e.stopPropagation();
},
className() {
return ['entry', this.model.get('type')].join(' ');
},
renderPending() {
this.$el.addClass('pending');
},
renderDone() {
this.$el.removeClass('pending');
},
renderSent() {
if (this.model.isOutgoing()) {
this.$el.toggleClass('sent', !!this.model.get('sent'));
}
},
renderDelivered() {
if (this.model.get('delivered')) {
this.$el.addClass('delivered');
}
},
renderRead() {
if (!_.isEmpty(this.model.get('read_by'))) {
this.$el.addClass('read');
}
},
onErrorsChanged() {
if (this.model.isIncoming()) {
this.render();
} else {
this.renderErrors();
}
},
renderErrors() {
const errors = this.model.get('errors');
getRenderInfo() {
const { Components } = window.Signal;
this.$('.error-icon-container').remove();
if (this.errorIconView) {
this.errorIconView.remove();
this.errorIconView = null;
}
if (_.size(errors) > 0) {
if (this.model.isIncoming()) {
this.$('.content')
.text(this.model.getDescription())
.addClass('error-message');
}
this.errorIconView = new ErrorIconView({ model: errors[0] });
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
} else if (!this.hasContents()) {
const el = this.$('.content');
if (!el || el.length === 0) {
this.$('.inner-bubble').append("<div class='content'></div>");
}
this.$('.content')
.text(i18n('noContents'))
.addClass('error-message');
if (this.model.isExpirationTimerUpdate()) {
return {
Component: Components.TimerNotification,
props: this.model.getPropsForTimerNotification(),
};
} else if (this.model.isKeyChange()) {
return {
Component: Components.SafetyNumberNotification,
props: this.model.getPropsForSafetyNumberNotification(),
};
} else if (this.model.isVerifiedChange()) {
return {
Component: Components.VerificationNotification,
props: this.model.getPropsForVerificationNotification(),
};
} else if (this.model.isEndSession()) {
return {
Component: Components.ResetSessionNotification,
props: this.model.getPropsForResetSessionNotification(),
};
} else if (this.model.isGroupUpdate()) {
return {
Component: Components.GroupNotification,
props: this.model.getPropsForGroupNotification(),
};
}
this.$('.meta .hasRetry').remove();
if (this.networkErrorView) {
this.networkErrorView.remove();
this.networkErrorView = null;
}
if (this.model.hasNetworkError()) {
this.networkErrorView = new NetworkErrorView({ model: this.model });
this.$('.meta').prepend(this.networkErrorView.render().el);
}
this.$('.meta .some-failed').remove();
if (this.someFailedView) {
this.someFailedView.remove();
this.someFailedView = null;
}
if (this.model.someRecipientsFailed()) {
this.someFailedView = new SomeFailedView();
this.$('.meta').prepend(this.someFailedView.render().el);
}
},
renderControl() {
if (this.model.isEndSession() || this.model.isGroupUpdate()) {
this.$el.addClass('control');
if (this.controlView) {
this.controlView.remove();
this.controlView = null;
}
this.controlView = new Whisper.ReactWrapperView({
className: 'content-wrapper',
Component: window.Signal.Components.Emojify,
props: {
text: this.model.getDescription(),
},
});
this.$('.content').prepend(this.controlView.el);
} else {
this.$el.removeClass('control');
}
},
renderExpiring() {
if (!this.timerView) {
this.timerView = new TimerView({ model: this.model });
}
this.timerView.setElement(this.$('.timer'));
this.timerView.update();
},
renderQuote() {
const props = this.model.getPropsForQuote();
if (!props) {
return;
}
const contact = this.model.getQuoteContact();
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
} else if (contact) {
this.listenTo(contact, 'change:color', this.renderQuote);
}
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
props: Object.assign({}, props, {
text: props.text,
}),
});
this.$('.inner-bubble').prepend(this.quoteView.el);
},
renderContact() {
const contacts = this.model.get('contact');
if (!contacts || !contacts.length) {
return;
}
const contact = contacts[0];
const regionCode = storage.get('regionCode');
const { contactSelector } = Signal.Types.Contact;
const number =
contact.number && contact.number[0] && contact.number[0].value;
const haveConversation =
number && Boolean(window.ConversationController.get(number));
const hasLocalSignalAccount =
this.contactHasSignalAccount || (number && haveConversation);
// We store this value on this. because a re-render shouldn't kick off another
// profile check, going to the web.
this.contactHasSignalAccount = hasLocalSignalAccount;
const onSendMessage = number
? () => {
this.model.trigger('open-conversation', number);
}
: null;
const onOpenContact = async () => {
// First let's finish our check with the central server to see if this user has
// a signal account. Then we won't have to do it a second time for the detail
// screen.
await this.checkingProfile;
this.model.trigger('show-contact-detail', {
contact,
hasSignalAccount: this.contactHasSignalAccount,
});
return {
Component: Components.Message,
props: this.model.getPropsForMessage(),
};
const getProps = ({ hasSignalAccount }) => ({
contact: contactSelector(contact, {
regionCode,
getAbsoluteAttachmentPath,
}),
hasSignalAccount,
onSendMessage,
onOpenContact,
});
if (this.contactView) {
this.contactView.remove();
this.contactView = null;
}
this.contactView = new Whisper.ReactWrapperView({
className: 'contact-wrapper',
Component: window.Signal.Components.EmbeddedContact,
props: getProps({
hasSignalAccount: hasLocalSignalAccount,
}),
});
this.$('.inner-bubble').prepend(this.contactView.el);
// If we can't verify a signal account locally, we'll go to the Signal Server.
if (number && !hasLocalSignalAccount) {
// eslint-disable-next-line more/no-then
this.checkingProfile = window.textsecure.messaging
.getProfile(number)
.then(() => {
this.contactHasSignalAccount = true;
if (!this.contactView) {
return;
}
this.contactView.update(getProps({ hasSignalAccount: true }));
})
.catch(() => {
// No account available, or network connectivity problem
});
} else {
this.checkingProfile = Promise.resolve();
}
},
isImageWithoutCaption() {
const attachments = this.model.get('attachments');
const body = this.model.get('body');
if (!attachments || attachments.length === 0) {
return false;
}
if (body && body.trim()) {
return false;
}
const first = attachments[0];
if (Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
return true;
}
return false;
},
hasContents() {
const attachments = this.model.get('attachments');
const hasAttachments = attachments && attachments.length > 0;
const contacts = this.model.get('contact');
const hasContact = contacts && contacts.length > 0;
return this.hasTextContents() || hasAttachments || hasContact;
},
hasTextContents() {
const body = this.model.get('body');
const isGroupUpdate = this.model.isGroupUpdate();
const isEndSession = this.model.isEndSession();
const errors = this.model.get('errors');
const hasErrors = errors && errors.length > 0;
const errorsCanBeContents = this.model.isIncoming() && hasErrors;
return body || isGroupUpdate || isEndSession || errorsCanBeContents;
},
addId() {
// Because we initially render a sent Message before we've roundtripped with the
// database, we don't have its id for that first render. We do get a change event,
// however, and can add the id manually.
const { id } = this.model;
this.$el.attr('id', id);
},
render() {
const contact = this.model.isIncoming() ? this.model.getContact() : null;
const attachments = this.model.get('attachments');
this.addId();
const errors = this.model.get('errors');
const hasErrors = errors && errors.length > 0;
const hasAttachments = attachments && attachments.length > 0;
const hasBody = this.hasTextContents();
const messageBody = this.model.get('body');
this.$el.html(
Mustache.render(
_.result(this, 'template', ''),
{
message: Boolean(messageBody),
hasBody,
timestamp: this.model.get('sent_at'),
sender: (contact && contact.getTitle()) || '',
avatar: contact && contact.getAvatar(),
profileName: contact && contact.getProfileName(),
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
hoverIcon: !hasErrors,
hasAttachments,
reply: i18n('replyToMessage'),
},
this.render_partials()
)
);
this.timeStampView.setElement(this.$('.timestamp'));
this.timeStampView.update();
this.renderControl();
if (messageBody) {
if (this.bodyView) {
this.bodyView.remove();
this.bodyView = null;
}
this.bodyView = new Whisper.ReactWrapperView({
className: 'body-wrapper',
Component: window.Signal.Components.MessageBody,
props: {
text: messageBody,
},
});
this.$('.body').append(this.bodyView.el);
if (this.childView) {
this.childView.remove();
this.childView = null;
}
this.renderSent();
this.renderDelivered();
this.renderRead();
this.renderErrors();
this.renderExpiring();
this.renderQuote();
this.renderContact();
const { Component, props } = this.getRenderInfo();
this.childView = new Whisper.ReactWrapperView({
className: 'message-wrapper',
Component,
props,
});
// NOTE: We have to do this in the background (`then` instead of `await`)
// as our code / Backbone seems to rely on `render` synchronously returning
// `this` instead of `Promise MessageView` (this):
// eslint-disable-next-line more/no-then
this.loadAttachmentViews().then(views =>
this.renderAttachmentViews(views)
);
const update = () => {
const info = this.getRenderInfo();
this.childView.update(info.props);
};
this.listenTo(this.model, 'change', update);
this.conversation = this.model.getConversation();
this.listenTo(this.conversation, 'change', update);
this.fromContact = this.model.getIncomingContact();
if (this.fromContact) {
this.listenTo(this.fromContact, 'change', update);
}
this.quotedContact = this.model.getQuoteContact();
if (this.quotedContact) {
this.listenTo(this.quotedContact, 'change', update);
}
this.$el.append(this.childView.el);
return this;
},
updateColor() {
const bubble = this.$('.bubble');
// this.contact is known to be non-null if we're registered for color changes
const color = this.contact.getColor();
if (color) {
bubble.removeClass(Whisper.Conversation.COLORS);
bubble.addClass(color);
}
this.avatarView = new (Whisper.View.extend({
templateName: 'avatar',
render_attributes: { avatar: this.contact.getAvatar() },
}))();
this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar'));
},
loadAttachmentViews() {
if (this.loadedAttachmentViews !== null) {
return this.loadedAttachmentViews;
}
const attachments = this.model.get('attachments') || [];
const loadedAttachmentViews = Promise.all(
attachments.map(
attachment =>
new Promise(async resolve => {
const attachmentWithData = await loadAttachmentData(attachment);
const view = new Whisper.AttachmentView({
model: attachmentWithData,
timestamp: this.model.get('sent_at'),
});
this.listenTo(view, 'update', () => {
// NOTE: Can we do without `updated` flag now that we use promises?
view.updated = true;
resolve(view);
});
view.render();
})
)
);
// Memoize attachment views to avoid double loading:
this.loadedAttachmentViews = loadedAttachmentViews;
return loadedAttachmentViews;
},
renderAttachmentViews(views) {
views.forEach(view => this.renderAttachmentView(view));
},
renderAttachmentView(view) {
if (!view.updated) {
throw new Error(
'Invariant violation:' +
' Cannot render an attachment view that isnt ready'
);
}
const parent = this.$('.attachments')[0];
const isViewAlreadyChild = parent === view.el.parentNode;
if (isViewAlreadyChild) {
return;
}
if (view.el.parentNode) {
view.el.parentNode.removeChild(view.el);
}
this.trigger('beforeChangeHeight');
this.$('.attachments').append(view.el);
view.setElement(view.el);
this.trigger('afterChangeHeight');
},
});
})();

View File

@ -7,7 +7,7 @@
window.Whisper = window.Whisper || {};
Whisper.ScrollDownButtonView = Whisper.View.extend({
className: 'scroll-down-button-view',
className: 'module-scroll-down',
templateName: 'scroll-down-button-view',
initialize(options = {}) {
@ -20,7 +20,8 @@
},
render_attributes() {
const cssClass = this.count > 0 ? 'new-messages' : '';
const buttonClass =
this.count > 0 ? 'module-scroll-down__button--new-messages' : '';
let moreBelow = i18n('scrollDown');
if (this.count > 1) {
@ -30,7 +31,7 @@
}
return {
cssClass,
buttonClass,
moreBelow,
};
},

View File

@ -77,6 +77,7 @@
"protobufjs": "^6.8.6",
"proxy-agent": "^2.1.0",
"react": "^16.2.0",
"react-contextmenu": "^2.9.2",
"react-dom": "^16.2.0",
"read-last-lines": "^1.3.0",
"rimraf": "^2.6.2",

View File

@ -202,13 +202,6 @@ window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInst
window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat;
window.loadImage = require('blueimp-load-image');
// Note: when modifying this file, consider whether our React Components or Backbone Views
// will need these things to render in the Style Guide. If so, go update one of these
// two locations:
//
// 1) test/styleguide/legacy_bridge.js
// 2) ts/styleguide/StyleGuideUtil.js
window.React = require('react');
window.ReactDOM = require('react-dom');
window.moment = require('moment');

View File

@ -54,98 +54,6 @@ module.exports = {
},
],
},
body: {
// Brings in all the necessary components to boostrap Backbone views
// Mirrors the order used in background.js.
scripts: [
{
src: 'test/styleguide/legacy_bridge.js',
},
{
src: 'node_modules/moment/min/moment-with-locales.min.js',
},
{
src: 'js/components.js',
},
{
src: 'js/reliable_trigger.js',
},
{
src: 'js/database.js',
},
{
src: 'js/storage.js',
},
{
src: 'js/signal_protocol_store.js',
},
{
src: 'js/libtextsecure.js',
},
{
src: 'js/focus_listener.js',
},
{
src: 'js/notifications.js',
},
{
src: 'js/delivery_receipts.js',
},
{
src: 'js/read_receipts.js',
},
{
src: 'js/read_syncs.js',
},
{
src: 'js/libphonenumber-util.js',
},
{
src: 'js/models/messages.js',
},
{
src: 'js/models/conversations.js',
},
{
src: 'js/models/blockedNumbers.js',
},
{
src: 'js/expiring_messages.js',
},
{
src: 'js/chromium.js',
},
{
src: 'js/registration.js',
},
{
src: 'js/expire.js',
},
{
src: 'js/conversation_controller.js',
},
// Select Backbone views
{
src: 'js/views/react_wrapper_view.js',
},
{
src: 'js/views/whisper_view.js',
},
{
src: 'js/views/timestamp_view.js',
},
{
src: 'js/views/attachment_view.js',
},
{
src: 'js/views/message_view.js',
},
// Hacky way of including templates for Backbone components
{
src: 'test/styleguide/legacy_templates.js',
},
],
},
},
propsParser,
webpackConfig: {

View File

@ -1,85 +1,10 @@
.conversation-title {
display: block;
line-height: 36px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 46px;
-webkit-user-select: text;
}
.conversation-name + .conversation-number {
&:before {
content: '\00b7'; // &middot
font-weight: bold;
padding: 0 5px 0 4px;
}
}
.conversation-title .verified {
&:before {
content: '\00b7'; // &middot
font-weight: bold;
padding: 0 5px 0 4px;
}
}
.conversation-title .verified-icon {
@include color-svg('../images/verified-check.svg', white);
display: inline-block;
width: 1.25em;
height: 1.25em;
vertical-align: text-bottom;
}
.conversation {
background-color: white;
background-color: $color-white;
height: 100%;
position: relative;
.conversation-loading-screen {
z-index: 99;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: #eee;
display: flex;
align-items: center;
.content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container {
position: absolute;
left: 50%;
width: 78px;
transform: translate(-50%, 0);
}
.dot {
width: 14px;
height: 14px;
border: 3px solid $blue;
border-radius: 50%;
float: left;
margin: 0 6px;
transform: scale(0);
animation: loading 1500ms ease infinite 0ms;
&:nth-child(2) {
animation: loading 1500ms ease infinite 333ms;
}
&:nth-child(3) {
animation: loading 1500ms ease infinite 666ms;
}
}
}
.panel,
.react-wrapper {
.panel-wrapper {
height: calc(100% - #{$header-height});
overflow-y: scroll;
}
@ -94,7 +19,7 @@
}
.main.panel,
.react-wrapper {
.panel-wrapper {
display: flex;
flex-direction: column;
overflow: initial;
@ -124,8 +49,13 @@
}
}
.message-detail-wrapper {
height: calc(100% - 48px);
width: 100%;
}
.discussion-container {
background-color: 'white';
background-color: $color-white;
}
.key-verification {
@ -142,10 +72,10 @@
display: inline-block;
&.verified {
@include color-svg('../images/verified-check.svg', $grey_d);
@include color-svg('../images/verified-check.svg', $color-light-90);
}
&.shield {
@include color-svg('../images/shield.svg', $grey_d);
@include color-svg('../images/shield.svg', $color-light-90);
}
}
@ -199,291 +129,20 @@
}
}
.identity-key-send-error {
button {
margin-top: 0px;
margin-bottom: 0px;
}
.explanation {
margin-top: 20px;
}
.safety-number {
margin-top: 30px;
text-align: center;
}
.actions {
margin-top: 30px;
text-align: center;
}
}
.message-detail {
background-color: #eee;
.message-container {
padding: 20px 0;
.sender {
display: none;
}
}
.info {
padding: 1em;
.label {
font-weight: bold;
padding-right: 1em;
vertical-align: top;
}
button {
border: none;
border-radius: $border-radius;
color: white;
padding: 0.5em;
font-weight: bold;
span {
vertical-align: middle;
}
}
}
.retries {
padding: 1em;
}
button.retry {
margin: 0.5em;
}
.contacts .contact-detail {
padding: 0 36px;
margin-bottom: 5px;
.status-icon-container,
.error-icon-container {
float: right;
}
button.error {
background-color: red;
color: white;
span.icon.error {
display: inline-block;
width: 1.25em;
height: 1.25em;
position: relative;
vertical-align: middle;
@include color-svg('../images/warning.svg', white);
}
}
.error-message {
margin: 6px 0 0;
font-size: $font-size-small;
font-weight: bold;
color: red;
}
}
h3 {
font-size: 1em;
padding: 5px;
}
button.cancel {
float: right;
color: $grey_d;
border: solid 1px #ccc;
}
.delete-container {
text-align: center;
button.delete {
background-color: red;
color: white;
}
}
}
.message-list {
.error-icon {
cursor: pointer;
}
.advisory {
text-align: center;
.content {
display: inline-block;
padding: 5px 10px;
background: #fff5c4;
border-radius: $border-radius;
}
}
}
li.entry .error-icon-container {
position: absolute;
top: 0;
left: calc(100% + 5px);
height: 100%;
.error-icon {
display: block;
height: 100%;
}
.error-message {
display: none;
position: absolute;
background: black;
color: white;
border-radius: $border-radius;
padding: 0.5em;
font-weight: normal;
bottom: calc(50% + 18px);
left: -84px;
width: 180px;
z-index: 10;
&:before {
display: block;
content: '';
position: absolute;
bottom: -16px;
left: 50%;
border: 6px solid transparent;
border-top: 10px solid #000000;
}
}
&:hover .error-message {
display: inline-block;
}
}
li.entry .menu-container {
position: absolute;
top: 0;
left: calc(100% + 5px);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.menu-anchor {
position: relative;
}
li {
margin: 0px;
}
cursor: pointer;
}
.dots-horizontal-icon {
visibility: hidden;
}
li.entry:hover .dots-horizontal-icon {
visibility: visible;
}
li.entry.outgoing .menu-container {
left: auto;
right: calc(100% + 5px);
}
.incoming .menu-list {
left: 0;
right: auto;
}
.error-icon {
display: inline-block;
width: $error-icon-size;
height: $error-icon-size;
position: relative;
@include color-svg('../images/warning.svg', red);
}
.dots-horizontal-icon {
display: inline-block;
width: $error-icon-size;
height: $error-icon-size;
position: relative;
@include color-svg('../images/dots-horizontal.svg', gray);
&:hover {
@include color-svg('../images/dots-horizontal.svg', black);
}
}
.group {
li.entry .unregistered-user-error {
display: none;
}
}
.group-update {
font-size: smaller;
}
.private .entry .avatar,
.private .sender,
.outgoing .sender {
display: none;
}
.sender {
font-size: smaller;
opacity: 0.8;
margin-bottom: 5px;
font-weight: bold;
}
.timestamp {
margin-right: 3px;
white-space: nowrap;
}
// There's a p.status used in the onboarding screen, so this needs to be more specific
span.status {
width: 18px;
height: 18px;
}
.sent span.status {
display: inline-block;
@include color-svg('../images/check.svg', black);
}
.delivered span.status {
display: inline-block;
@include color-svg('../images/double-check.svg', black);
}
.read span.status {
display: inline-block;
@include color-svg('../images/double-check.svg', $blue);
}
.pending span.status {
display: inline-block;
background: none;
&:before {
content: '...';
}
}
.message-container,
.message-list {
list-style: none;
li {
max-width: 800px;
margin: 0 auto 10px;
padding-left: 1em;
// we need more padding on right side because scroll bar overlaps
padding-right: 1.5em;
max-width: 736px;
margin-left: auto;
margin-right: auto;
margin-bottom: 10px;
.message-wrapper {
margin-left: 16px;
margin-right: 16px;
}
&::after {
visibility: hidden;
@ -494,252 +153,14 @@ span.status {
height: 0;
}
}
}
.bubble {
position: relative;
left: -2px;
display: inline-block;
vertical-align: top;
word-wrap: break-word;
margin-left: 8px;
max-width: 30em;
text-align: -webkit-auto;
-webkit-user-select: text;
@media (max-width: 825px) {
max-width: calc(
100% - 45px - #{$error-icon-size}
); // avatar size + padding + error-icon size
.group {
.message-container,
.message-list {
li .message-wrapper {
margin-left: 44px;
}
.body {
white-space: pre-wrap;
a {
word-break: break-all;
}
}
.attachments + .content {
margin-top: 0.5em;
}
.quote-wrapper + .content {
margin-top: 0.5em;
}
.contact-wrapper + .content {
margin-top: 0.5em;
}
p {
margin: 0;
}
}
.meta {
font-size: smaller;
margin-top: 3px;
text-align: right;
line-height: 18px;
.hasRetry + .timestamp {
&:before {
content: '\00b7'; // &middot
font-weight: bold;
padding: 0 5px 0 4px;
text-decoration: none;
opacity: 0.5;
}
}
.retry {
text-decoration: underline;
cursor: pointer;
}
.some-failed {
float: left;
margin-left: 6px;
margin-right: 6px;
cursor: pointer;
}
.hasRetry,
.timestamp,
.status,
.timer {
float: left;
}
.timestamp,
.status {
cursor: pointer;
opacity: 0.5;
&:hover {
opacity: 1;
}
}
}
.incoming {
.avatar,
.bubble {
float: left;
}
}
.outgoing {
.meta {
float: right;
}
.error-icon-container {
left: auto;
right: calc(100% + 5px);
}
.avatar,
.bubble {
float: right;
}
.bubble {
clear: left;
}
}
@keyframes shake {
0% {
transform: translateX(0px);
}
25% {
transform: translateX(-5px);
}
50% {
transform: translateX(0px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0px);
}
}
.expired .bubble {
animation: shake 0.2s linear 3;
}
.timer {
display: none;
.hourglass {
vertical-align: middle;
}
}
.control {
.bubble {
.content {
font-style: italic;
}
&::before,
&::after {
display: none;
}
}
}
.attachments {
a {
font-style: italic;
display: block;
padding: 1em;
background-color: #ccc;
}
img,
audio,
video {
display: block;
max-width: 100%;
max-height: 300px;
}
video {
background: black;
min-height: 300px;
min-width: 280px;
}
img {
cursor: pointer;
}
.fileView {
display: flex;
align-items: center;
overflow: hidden;
position: relative;
padding: 5px;
padding-right: 10px;
padding-bottom: 0px;
cursor: pointer;
.fileName {
font-weight: bold;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.text {
overflow: hidden;
}
.icon,
.text {
opacity: 0.75;
}
&:hover {
.icon,
.text {
opacity: 1;
}
}
.icon {
margin-left: -0.5em;
margin-right: 0.5em;
display: inline-block;
vertical-align: middle;
width: $button-height * 2;
height: $button-height * 2;
@include color-svg('../images/file.svg', $grey_d);
&.audio {
@include color-svg('../images/audio.svg', $grey_d);
}
&.video {
@include color-svg('../images/video.svg', $grey_d);
}
&.voice {
@include color-svg('../images/voice.svg', $grey_d);
}
}
}
}
.outgoing .avatar {
display: none;
}
.bubble .content.error-message {
cursor: pointer;
font-style: italic;
}
}
@ -762,45 +183,23 @@ span.status {
}
.send .quote-wrapper {
margin-left: 46px;
margin-top: 5px;
margin-right: 75px;
margin-bottom: 0px;
}
.incoming .quoted-message {
background-color: rgba(white, 0.6);
border-top: none;
border-bottom: none;
border-right: none;
border-left-color: white;
}
.message-list,
.message-container {
.avatar {
height: 36px;
width: 36px;
line-height: 36px;
}
margin-left: 37px;
margin-right: 73px;
margin-bottom: 5px;
}
.bottom-bar {
box-sizing: content-box;
$button-width: 36px;
padding: 5px 0px 5px 0;
background: $grey_l;
.compose {
padding-right: 5px;
}
form.active {
outline: solid 1px $blue;
textarea {
border: solid 1px $blue;
}
}
form.send {
background: #ffffff;
background: $color-white;
&.video-attachment {
.image-container {
@ -829,13 +228,9 @@ span.status {
}
}
input,
textarea {
color: $grey_d;
}
.attachment-previews {
padding: 0 36px;
margin-bottom: 3px;
.attachment-preview {
padding: 13px 10px 0;
@ -875,8 +270,11 @@ span.status {
display: block;
max-height: 100px;
padding: 10px;
margin: 0 5px;
border: 0;
margin-bottom: 6px;
border-radius: 20px;
background-color: $color-light-02;
color: $color-light-90;
border: 1px solid rgba(0, 0, 0, 0.2);
outline: 0;
z-index: 5;
resize: none;
@ -903,7 +301,7 @@ span.status {
margin: 0 2em 3em;
padding: 0.5em 1.5em;
background: rgba(0, 0, 0, 0.75);
color: white;
color: $color-white;
box-shadow: 0 0 5px 0 black;
border-radius: $border-radius;
font-size: $font-size-small;
@ -957,109 +355,105 @@ span.status {
}
}
.advisory .icon {
height: 1.25em;
width: 1.25em;
vertical-align: text-bottom;
display: inline-block;
&.verified {
@include color-svg('../images/verified-check.svg', $grey_d);
}
&.shield {
@include color-svg('../images/shield.svg', $grey_d);
}
&.clock {
@include color-svg('../images/clock.svg', $grey_d);
}
}
.keychange {
text-align: center;
.content {
cursor: pointer;
display: inline-block;
padding: 5px 10px;
background: #fff5c4;
border-radius: $border-radius;
}
}
.verified-change {
text-align: center;
.conversation-loading-screen {
z-index: 99;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
background-color: $color-white;
.content {
cursor: pointer;
display: inline-block;
padding: 5px 10px;
background: #fff5c4;
border-radius: $border-radius;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container {
position: absolute;
left: 50%;
width: 120px;
transform: translate(-50%, 0);
}
.dot {
width: 14px;
height: 14px;
border: 3px solid $blue;
border-radius: 50%;
float: left;
margin: 0 6px;
transform: scale(0);
animation: loading 1500ms ease infinite 0ms;
&:nth-child(2) {
animation: loading 1500ms ease infinite 333ms;
}
&:nth-child(3) {
animation: loading 1500ms ease infinite 666ms;
}
}
}
.message-list .last-seen-indicator-view {
// This padding is large so we clear the avatar circle extending into the conversation
// window.scrollIntoView() doesn't honor margins, so we're using padding
// padding-top is less to account for the 10px margin at the bottom of messages
.module-last-seen-indicator {
padding-top: 25px;
padding-bottom: 35px;
.bar {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
border-bottom: 1px solid rgba(0, 0, 0, 0.055);
background-color: rgba(0, 0, 0, 0.05);
}
.text {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
background-color: white;
border-radius: 1.5em;
padding: 10px 21px 9px 21px;
}
margin-left: 28px;
margin-right: 28px;
}
.discussion-container .scroll-down-button-view {
.module-last-seen-indicator__bar {
background-color: $color-light-60;
width: 100%;
height: 4px;
}
.module-last-seen-indicator__text {
margin-top: 3px;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
text-transform: uppercase;
text-align: center;
color: $color-light-90;
}
.module-scroll-down {
position: absolute;
right: 20px;
bottom: 10px;
}
button {
height: 44px;
width: 44px;
border-radius: 22px;
text-align: center;
background-color: white;
border: none;
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
outline: none;
.module-scroll-down__button {
height: 44px;
width: 44px;
border-radius: 22px;
text-align: center;
background-color: $color-light-35;
border: none;
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
outline: none;
.icon {
@include color-svg('../images/down.svg', $grey_l3);
height: 100%;
width: 100%;
}
.icon:hover {
background-color: #616161;
}
&.new-messages {
background-color: $blue;
.icon {
@include color-svg('../images/down.svg', white);
}
&:hover {
background-color: #1472bd;
}
}
&:hover {
background-color: $color-light-45;
}
}
.module-scroll-down__button--new-messages {
background-color: $color-signal-blue;
&:hover {
background-color: #1472bd;
}
}
.module-scroll-down__icon {
@include color-svg('../images/down.svg', $color-white);
height: 100%;
width: 100%;
}

View File

@ -114,6 +114,17 @@ button.emoji {
.emoji-panel-container {
height: 0px;
margin-bottom: 3px;
.ep-emojies {
background-color: $color-white;
}
.ep-categories {
background-color: $color-light-10;
margin-bottom: 6px;
}
.ep-e {
background-image: url('../node_modules/emoji-datasource-apple/img/apple/sheets/64.png');
background-size: 1734px;

View File

@ -13,7 +13,7 @@ body {
margin: 0;
font-family: $roboto;
font-size: 14px;
color: $grey_d;
color: $color-light-90;
}
.dark-overlay {
@ -22,7 +22,7 @@ body {
left: 0;
right: 0;
bottom: 0;
background-color: black;
background-color: $color-black;
opacity: 0.25;
z-index: 200;
}
@ -40,23 +40,21 @@ body {
display: none;
}
#header {
h1 {
margin: 0;
line-height: $header-height;
padding-left: 20px;
font-size: 22px;
font-weight: normal;
}
.title-bar {
color: $color-light-90;
height: $header-height;
display: flex;
flex-direction: row;
align-items: center;
}
.conversation-header button,
.title-bar button {
width: $button-height;
height: $button-height;
line-height: $button-height;
padding: 0;
border: 0;
.logo {
margin-left: 16px;
font-size: 16px;
line-height: 24px;
font-weight: 300;
color: $color-light-90;
}
button {
@ -92,103 +90,6 @@ a {
color: $blue;
}
button.back {
@include header-icon-black('../images/back.svg');
}
button.clock {
@include header-icon-black('../images/clock.svg');
}
button.hamburger {
@include header-icon-black('../images/menu.svg');
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: $border-radius;
&:hover {
background: rgba(0, 0, 0, 0.25);
}
}
.header-buttons {
&.left {
float: left;
padding-left: 10px;
}
&.right {
float: right;
padding-right: 10px;
}
height: 0;
.vertical-align {
height: $header-height;
vertical-align: middle;
display: table-cell;
}
}
.conversation-header .timer-menu {
margin-right: 10px;
&:before {
content: attr(data-time);
display: inline-block;
position: absolute;
bottom: -10px;
height: 10px;
width: 100%;
text-align: center;
font-size: 8px;
font-weight: bold;
}
}
.menu {
position: relative;
float: right;
.hamburger {
width: $button-height;
height: $button-height;
vertical-align: middle;
}
.menu-list {
display: none;
position: absolute;
color: $grey_d;
z-index: 50;
text-align: initial;
top: 100%;
right: 0;
margin: 0;
padding: 0;
background-color: white;
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2);
li {
display: block;
white-space: nowrap;
cursor: pointer;
padding: 5px 15px 5px 10px;
&:hover {
background-color: $grey_l;
}
}
}
}
.file-input {
position: relative;
.choose-file {
@ -248,13 +149,13 @@ $avatar-size: 44px;
line-height: $avatar-size;
overflow-x: hidden;
text-overflow: ellipsis;
color: white;
color: $color-white;
font-size: 18px;
@include avatar-colors;
background-color: $grey;
}
.group-info-input {
background: white;
background: $color-white;
.group-avatar {
display: inline-block;
@ -303,23 +204,15 @@ $avatar-size: 44px;
}
}
}
// the old way
.profileName {
font-size: smaller;
&:before {
content: '~';
}
}
// the new way
.profile-name {
font-size: smaller;
}
$unread-badge-size: 21px;
.conversation-list-item {
cursor: pointer;
color: $color-light-90;
&:hover {
background: #f8f8f8;
background: $color-black-008;
}
.number {
@ -351,12 +244,6 @@ $avatar-size: 44px;
padding: 12px;
white-space: nowrap;
overflow: hidden;
background: rgba(255, 255, 255, 0.6);
margin: 1px;
&.selected {
background: rgb(236, 243, 252);
}
&:first-child {
margin-top: 0;
@ -511,6 +398,8 @@ $avatar-size: 44px;
content: 'Add: ';
}
$loading-height: 16px;
.loading {
position: relative;
&::before {

View File

@ -1,29 +0,0 @@
@mixin hourglass($color) {
display: inline-block;
position: relative;
@include color-svg('../images/hourglass_full.svg', transparent);
background-size: 100%;
&,
.sand,
&:before,
&:after {
width: 13px;
height: 11px;
}
.sand,
&:before,
&:after {
content: '';
display: inline-block;
position: absolute;
top: 0;
left: 0;
}
.sand {
background: $color;
}
&:after {
@include color-svg('../images/hourglass_empty.svg', $color);
}
}

View File

@ -3,12 +3,13 @@
.inbox,
.gutter {
height: 100%;
overflow: hidden;
}
.expired {
.conversation-stack,
.gutter {
height: calc(100% - 56px);
height: calc(100% - 48px);
}
}
@ -18,16 +19,12 @@
}
.gutter {
color: $grey_d;
background-color: $color-black-008;
float: left;
width: 300px;
display: flex;
flex-direction: column;
.content {
background-color: $grey_l;
flex-grow: 1;
overflow-y: auto;
overflow-y: scroll;
max-height: calc(100% - 88px);
}
}
.network-status-container {
@ -66,8 +63,6 @@
}
.conversation-stack {
padding-left: 300px;
.conversation {
display: none;
}
@ -76,62 +71,49 @@
}
}
.conversation-header {
height: $header-height;
text-align: center;
color: white;
background-color: #999999;
transition: background-color 0.5s;
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
.avatar {
margin-bottom: -30px;
border: solid 2px white;
z-index: 10;
width: 48px;
height: 48px;
line-height: 44px;
position: relative;
}
}
.inactive .conversation-header {
background-color: $grey_l !important;
color: $grey_d;
border-color: rgba(0, 0, 0, 0.05);
.verified-icon {
@include color-svg('../images/verified-check.svg', $grey_d);
}
}
.tool-bar {
color: $color-light-90;
padding: 8px;
padding-top: 0px;
margin-top: -1px;
position: relative;
.search-icon {
content: '';
display: inline-block;
float: left;
width: 24px;
height: 100%;
height: 33px;
-webkit-mask: url('../images/search.svg') no-repeat left center;
-webkit-mask-size: 100%;
background-color: #ccc;
background-color: $color-light-35;
position: absolute;
left: 20px;
top: 0;
}
}
$search-x-size: 16px;
$search-padding-right: 12px;
$search-padding-left: 30px;
input.search {
border: none;
border: 1px solid $color-black-02;
padding: 0 $search-padding-right 0 $search-padding-left;
margin: 0;
margin-left: 8px;
margin-right: 8px;
outline: 0;
height: $search-height;
line-height: $search-height;
width: 100%;
border: solid 1px $grey_l;
height: 32px;
width: calc(100% - 16px);
outline-offset: -2px;
font-size: inherit;
font-size: 14px;
line-height: 18px;
font-weight: normal;
color: $color-light-35;
position: relative;
border-radius: 4px;
&.active {
outline: solid 1px $blue;

View File

@ -5,7 +5,7 @@
left: 0;
width: 100%;
height: 100%;
z-index: $z-index-modal;
z-index: 100;
}
.iconButton {

View File

@ -28,333 +28,3 @@
@include color-svg($svg, black);
}
}
@mixin avatar-colors {
&.red {
background-color: $material_red;
}
&.pink {
background-color: $material_pink;
}
&.purple {
background-color: $material_purple;
}
&.deep_purple {
background-color: $material_deep_purple;
}
&.indigo {
background-color: $material_indigo;
}
&.blue {
background-color: $material_blue;
}
&.light_blue {
background-color: $material_light_blue;
}
&.cyan {
background-color: $material_cyan;
}
&.teal {
background-color: $material_teal;
}
&.green {
background-color: $material_green;
}
&.light_green {
background-color: $material_light_green;
}
&.orange {
background-color: $material_orange;
}
&.deep_orange {
background-color: $material_deep_orange;
}
&.amber {
background-color: $material_amber;
}
&.blue_grey {
background-color: $material_blue_grey;
}
&.grey {
background-color: #999999;
}
&.default {
background-color: $blue;
}
}
@mixin dark-avatar-colors {
&.red {
background-color: $dark_material_red;
}
&.pink {
background-color: $dark_material_pink;
}
&.purple {
background-color: $dark_material_purple;
}
&.deep_purple {
background-color: $dark_material_deep_purple;
}
&.indigo {
background-color: $dark_material_indigo;
}
&.blue {
background-color: $dark_material_blue;
}
&.light_blue {
background-color: $dark_material_light_blue;
}
&.cyan {
background-color: $dark_material_cyan;
}
&.teal {
background-color: $dark_material_teal;
}
&.green {
background-color: $dark_material_green;
}
&.light_green {
background-color: $dark_material_light_green;
}
&.orange {
background-color: $dark_material_orange;
}
&.deep_orange {
background-color: $dark_material_deep_orange;
}
&.amber {
background-color: $dark_material_amber;
}
&.blue_grey {
background-color: $dark_material_blue_grey;
}
&.grey {
background-color: #666666;
}
&.default {
background-color: $blue;
}
}
@mixin twenty-percent-colors {
&.red {
background-color: rgba($dark_material_red, 0.2);
}
&.pink {
background-color: rgba($dark_material_pink, 0.2);
}
&.purple {
background-color: rgba($dark_material_purple, 0.2);
}
&.deep_purple {
background-color: rgba($dark_material_deep_purple, 0.2);
}
&.indigo {
background-color: rgba($dark_material_indigo, 0.2);
}
&.blue {
background-color: rgba($dark_material_blue, 0.2);
}
&.light_blue {
background-color: rgba($dark_material_light_blue, 0.2);
}
&.cyan {
background-color: rgba($dark_material_cyan, 0.2);
}
&.teal {
background-color: rgba($dark_material_teal, 0.2);
}
&.green {
background-color: rgba($dark_material_green, 0.2);
}
&.light_green {
background-color: rgba($dark_material_light_green, 0.2);
}
&.orange {
background-color: rgba($dark_material_orange, 0.2);
}
&.deep_orange {
background-color: rgba($dark_material_deep_orange, 0.2);
}
&.amber {
background-color: rgba($dark_material_amber, 0.2);
}
&.blue_grey {
background-color: rgba($dark_material_blue_grey, 0.2);
}
&.grey {
background-color: rgba(#666666, 0.2);
}
&.default {
background-color: rgba($blue, 0.2);
}
}
@mixin text-colors {
&.red {
color: $material_red;
}
&.pink {
color: $material_pink;
}
&.purple {
color: $material_purple;
}
&.deep_purple {
color: $material_deep_purple;
}
&.indigo {
color: $material_indigo;
}
&.blue {
color: $material_blue;
}
&.light_blue {
color: $material_light_blue;
}
&.cyan {
color: $material_cyan;
}
&.teal {
color: $material_teal;
}
&.green {
color: $material_green;
}
&.light_green {
color: $material_light_green;
}
&.orange {
color: $material_orange;
}
&.deep_orange {
color: $material_deep_orange;
}
&.amber {
color: $material_amber;
}
&.blue_grey {
color: $material_blue_grey;
}
&.grey {
color: #999999;
}
&.default {
color: $blue;
}
}
// TODO: Deduplicate these! Can SASS functions generate property names?
@mixin message-replies-colors {
&.red {
border-left-color: $material_red;
}
&.pink {
border-left-color: $material_pink;
}
&.purple {
border-left-color: $material_purple;
}
&.deep_purple {
border-left-color: $material_deep_purple;
}
&.indigo {
border-left-color: $material_indigo;
}
&.blue {
border-left-color: $material_blue;
}
&.light_blue {
border-left-color: $material_light_blue;
}
&.cyan {
border-left-color: $material_cyan;
}
&.teal {
border-left-color: $material_teal;
}
&.green {
border-left-color: $material_green;
}
&.light_green {
border-left-color: $material_light_green;
}
&.orange {
border-left-color: $material_orange;
}
&.deep_orange {
border-left-color: $material_deep_orange;
}
&.amber {
border-left-color: $material_amber;
}
&.blue_grey {
border-left-color: $material_blue_grey;
}
&.grey {
border-left-color: #999999;
}
&.default {
border-left-color: $blue;
}
}
@mixin dark-message-replies-colors {
&.red {
border-left-color: $dark_material_red;
}
&.pink {
border-left-color: $dark_material_pink;
}
&.purple {
border-left-color: $dark_material_purple;
}
&.deep_purple {
border-left-color: $dark_material_deep_purple;
}
&.indigo {
border-left-color: $dark_material_indigo;
}
&.blue {
border-left-color: $dark_material_blue;
}
&.light_blue {
border-left-color: $dark_material_light_blue;
}
&.cyan {
border-left-color: $dark_material_cyan;
}
&.teal {
border-left-color: $dark_material_teal;
}
&.green {
border-left-color: $dark_material_green;
}
&.light_green {
border-left-color: $dark_material_light_green;
}
&.orange {
border-left-color: $dark_material_orange;
}
&.deep_orange {
border-left-color: $dark_material_deep_orange;
}
&.amber {
border-left-color: $dark_material_amber;
}
&.blue_grey {
border-left-color: $dark_material_blue_grey;
}
&.grey {
border-left-color: #666666;
}
&.default {
border-left-color: $blue;
}
}
@mixin invert-text-color {
color: white;
&::selection {
background: white;
color: $grey_d;
}
}

View File

@ -14,7 +14,7 @@
max-width: 350px;
margin: 100px auto;
padding: 1em;
background: white;
background-color: $color-white;
border-radius: $border-radius;
overflow: auto;
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@
}
}
.recorder {
background: $grey_l;
background: $color-white;
button {
float: right;
@ -50,20 +50,20 @@
}
.finish {
background: lighten($green, 20%);
border: 1px solid $green;
background: lighten($color-core-green, 20%);
border: 1px solid $color-core-green;
.icon {
@include color-svg('../images/check.svg', $green);
@include color-svg('../images/check.svg', $color-core-green);
}
}
.close {
background: lighten($red, 20%);
border: 1px solid $red;
background: lighten($color-core-red, 20%);
border: 1px solid $color-core-red;
.icon {
@include color-svg('../images/x.svg', $red);
@include color-svg('../images/x.svg', $color-core-red);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,89 +0,0 @@
.light-theme {
#header {
background-color: $blue;
color: white;
transition: background-color 0.5s;
&.inactive {
background-color: $grey_l;
color: $grey_d;
}
}
.contact-details .name {
font-weight: 400;
}
.conversation.placeholder .conversation-header {
display: none;
}
.conversation-header,
.bubble {
@include avatar-colors;
}
.bottom-bar {
min-height: 10px;
}
.bubble {
padding: 9px 12px;
border-radius: $border-radius;
box-shadow: 0 3px 3px -4px black;
}
.outgoing .bubble {
background-color: white;
}
.outgoing .hourglass {
@include hourglass(#999);
}
.incoming .hourglass {
@include hourglass(#fff);
}
.incoming .bubble {
.sender,
.content,
.body,
.meta,
a,
.fileView {
@include invert-text-color;
}
.attachments,
.content {
a {
color: $grey_l;
}
}
}
.incoming .bubble .fileView .icon {
@include color-svg('../images/file.svg', white);
&.audio {
@include color-svg('../images/audio.svg', white);
}
&.video {
@include color-svg('../images/video.svg', white);
}
&.voice {
@include color-svg('../images/voice.svg', white);
}
}
button.clock {
@include header-icon-white('../images/clock.svg');
}
.inactive button.clock {
@include header-icon-black('../images/clock.svg');
}
button.hamburger {
@include header-icon-white('../images/menu.svg');
}
.inactive button.hamburger {
@include header-icon-black('../images/menu.svg');
}
button.back {
@include header-icon-white('../images/back.svg');
}
.inactive button.back {
@include header-icon-black('../images/back.svg');
}
}

View File

@ -1,19 +1,3 @@
// colors
$blue_l: #a2d2f4;
$blue: #2090ea;
$grey_l: #f3f3f3;
$grey_l1: #bdbdbd;
$grey_l1_5: #e6e6e6;
$grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles
$grey_l3: darken($grey_l, 20%);
$grey_l4: darken($grey_l, 40%);
$grey: #616161;
$grey_d: #454545;
$green: #47d647;
$red: #ef8989;
$z-index-modal: 100;
@font-face {
font-family: 'Roboto-Light';
src: url('../fonts/Roboto-Light.ttf') format('truetype');
@ -37,62 +21,69 @@ $z-index-modal: 100;
src: url('../fonts/Roboto-Bold.ttf') format('truetype');
font-weight: bold;
}
$roboto: Roboto, 'Helvetica Neue', Arial, Helvetica, sans-serif;
$roboto-light: Roboto-Light, 'Helvetica Neue', Arial, Helvetica, sans-serif;
$header-height: 64px;
// New colors
$color-signal-blue: #2090ea;
$color-core-green: #4caf50;
$color-core-red: #f44336;
$color-white: #ffffff;
$color-white-02: rgba($color-white, 0.2);
$color-white-07: rgba($color-white, 0.7);
$color-white-075: rgba($color-white, 0.75);
$color-light-02: #f9fafa;
$color-light-10: #eeefef;
$color-light-35: #a4a6a9;
$color-light-45: #8b8e91;
$color-light-60: #62656a;
$color-light-90: #070c14;
$color-dark-05: #efefef;
$color-dark-30: #a8a9aa;
$color-dark-55: #88898c;
$color-dark-60: #797a7c;
$color-dark-70: #414347;
$color-dark-85: #1a1c20;
$color-black: #000000;
$color-black-008: rgba($color-black, 0.08);
$color-black-012: rgba($color-black, 0.12);
$color-black-02: rgba($color-black, 0.2);
$color-black-04: rgba($color-black, 0.4);
$color-conversation-grey: #757575;
$color-conversation-blue: #1976d2;
$color-conversation-cyan: #00838f;
$color-conversation-deep_orange: #bf360c;
$color-conversation-green: #2e7d32;
$color-conversation-indigo: #3949ab;
$color-conversation-pink: #d81b60;
$color-conversation-purple: #8e24aa;
$color-conversation-red: #d32f2f;
$color-conversation-teal: #00796b;
// Old colors
$blue_l: #a2d2f4;
$blue: #2090ea;
$grey_l: #f3f3f3;
$grey_l1: #bdbdbd;
$grey_l1_5: #e6e6e6;
$grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles
$grey_l3: darken($grey_l, 20%);
$grey_l4: darken($grey_l, 40%);
$grey: #616161;
$grey_d: #454545;
// A few layout variables used cross-file
$header-height: 48px;
$button-height: 24px;
$header-color: $blue;
$search-height: 36px;
$search-padding-right: 10px;
$search-padding-left: 65px;
$search-padding-left-ios: 30px;
$search-x-size: 16px;
$unread-badge-size: 21px;
$loading-height: 16px;
$border-radius: 5px;
$error-icon-size: 24px;
$font-size: 14px;
$font-size-small: (13/14) + em;
$material_red: #ef5350;
$material_pink: #ec407a;
$material_purple: #ab47bc;
$material_deep_purple: #7e57c2;
$material_indigo: #5c6bc0;
$material_blue: #2196f3;
$material_light_blue: #03a9f4;
$material_cyan: #00bcd4;
$material_teal: #009688;
$material_green: #4caf50;
$material_light_green: #7cb342;
$material_orange: #ff9800;
$material_deep_orange: #ff5722;
$material_amber: #ffb300;
$material_blue_grey: #607d8b;
$dark_material_red: #d32f2f;
$dark_material_pink: #c2185b;
$dark_material_purple: #7b1fa2;
$dark_material_deep_purple: #512da8;
$dark_material_indigo: #303f9f;
$dark_material_blue: #1976d2;
$dark_material_light_blue: #0288d1;
$dark_material_cyan: #0097a7;
$dark_material_teal: #00796b;
$dark_material_green: #388e3c;
$dark_material_light_green: #689f38;
$dark_material_orange: #f57c00;
$dark_material_deep_orange: #e64a19;
$dark_material_amber: #ffa000;
$dark_material_blue_grey: #455a64;
// Android
$android-bubble-padding-horizontal: 12px;
$android-bubble-padding-vertical: 9px;
$android-bubble-quote-padding: 4px;

View File

@ -5,7 +5,6 @@
// Components
@import 'progress';
@import 'hourglass';
@import 'modal';
@import 'debugLog';
@import 'lightbox';
@ -16,7 +15,6 @@
// Build the main view
@import 'index';
@import 'conversation';
@import 'theme_light';
@import 'theme_dark';
// New CSS

View File

@ -12,7 +12,10 @@ describe('i18n', function() {
});
it('returns message with multiple substitutions', function() {
const actual = i18n('theyChangedTheTimer', ['Someone', '5 minutes']);
assert.equal(actual, 'Someone set the timer to 5 minutes.');
assert.equal(
actual,
'Someone set the disappearing message timer to 5 minutes'
);
});
});

View File

@ -96,37 +96,7 @@
<p> {{ content }}</p>
</script>
<script type='text/x-tmpl-mustache' id='conversation'>
<div class='conversation-header {{ avatar.color }}'>
<div class='header-buttons left'>
<div class='vertical-align'>
<button class='back hide'></button>
</div>
</div>
<div class='header-buttons right'>
<div class='vertical-align'>
<div class='conversation-menu menu'>
<button class='hamburger' alt='conversation menu'></button>
<ul class='menu-list'>
<li class='disappearing-messages'>{{ disappearing-messages }}</li>
{{#group}}
<li class='show-members'>{{ show-members }}</li>
<!-- <li class='update-group'>Update group</li> -->
<!-- <li class='leave-group'>Leave group</li> -->
{{/group}}
{{^group}}
{{ ^isMe }}
<li class='show-identity'>{{ show-identity }}</li>
{{ /isMe }}
<li class='end-session'>{{ end-session }}</li>
{{/group}}
<li class='destroy'>{{ destroy }}</li>
</ul>
</div>
</div>
</div>
<span class='conversation-title'></span>
{{> avatar }}
</div>
<div class='conversation-header'></div>
<div class='main panel'>
<div class='discussion-container'>
<div class='bar-container hide'>
@ -172,62 +142,6 @@
<img src='{{ source }}' class='preview' />
<a class='x close' alt='remove attachment' href='#'></a>
</script>
<script type='text/x-tmpl-mustache' id='hasRetry'>
{{ messageNotSent }}
<span href='#' class='retry'>{{ resend }}</span>
</script>
<script type='text/x-tmpl-mustache' id='keychange'>
<span class='content' dir='auto'><span class='shield icon'></span> {{ content }}</span>
</script>
<script type='text/x-tmpl-mustache' id='verified-change'>
<span class='content' dir='auto'><span class='{{ icon }} icon'></span> {{ content }}</span>
</script>
<script type='text/x-tmpl-mustache' id='message'>
{{> avatar }}
<div class='bubble {{ avatar.color }}'>
<div class='sender' dir='auto'>
{{ sender }}
{{ #profileName }}
<span class='profileName'>{{ profileName }} </span>
{{ /profileName }}
</div>
<div class='tail-wrapper {{ innerBubbleClasses }}'>
<div class='inner-bubble'>
{{ #hasAttachments }}
<div class='attachments'></div>
{{ /hasAttachments }}
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'></div>
{{ /message }}
</div>
{{ /hasBody }}
</div>
</div>
<div class='meta'>
<span class='timestamp' data-timestamp={{ timestamp }}></span>
<span class='status hide'></span>
<span class='timer'></span>
</div>
{{ #hoverIcon }}
<div class='menu-container menu'>
<div class='menu-anchor'>
<span class='dots-horizontal-icon'></span>
<ul class='menu-list'>
<li class='reply'>{{ reply }}</li>
</ul>
</div>
</div>
{{ /hoverIcon }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='hourglass'>
<span class='hourglass'><span class='sand'></span></span>
</script>
<script type='text/x-tmpl-mustache' id='expirationTimerUpdate'>
<span class='content'><span class='icon clock'></span> {{ content }}</span>
</script>
<script type='text/x-tmpl-mustache' id='new-group-update'>
<div class='conversation-header'>
<button class='back'></button>
@ -304,48 +218,6 @@
<script type='text/x-tmpl-mustache' id='attachment-type-modal'>
Sorry, your attachment has a type, {{type}}, that is not currently supported.
</script>
<script type='text/x-tmpl-mustache' id='message-detail'>
<div class='container'>
<div class='message-container'></div>
<div class='info'>
<table>
{{ #errors }}
<tr>
<td class='label'>{{ errorLabel }}</td>
<td> <span class='error-message'>{{message}}</span> </td>
</tr>
{{ /errors }}
<tr>
<td class='label'>{{ sent }}</td>
<td> {{ sent_at }}</td>
</tr>
{{ #received_at }}
<tr>
<td class='label'>{{ received }}</td>
<td> {{ received_at }}</td>
</tr>
{{ /received_at }}
<tr> <td class='tofrom label'>{{tofrom}}</td> </tr>
</table>
<div class='contacts'>
</div>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='identity-key-send-error'>
<div class='container'>
<div class='explanation'>
{{ errorExplanation }}
</div>
<div class='safety-number'>
<button class='show-safety-number grey'>{{ showSafetyNumber }}</button>
</div>
<div class='actions'>
<button class='send-anyway grey'>{{ sendAnyway }}</button>
<button class='cancel grey'>{{ cancel }}</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='group-member-list'>
<div class='container'>
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
@ -425,24 +297,6 @@
<span class='error-message'>{{message}}</span>
{{ /message }}
</script>
<script type='text/x-tmpl-mustache' id='contact-detail'>
<div class='clearfix'>
{{> avatar }}
<div class='contact-details'>
{{ #errors }}
<div class='error-icon-container'>
<span class='error-icon'></span>
</div>
{{ /errors }}
<span class='name' dir='auto'>{{ name }}</span>
{{ #errors }}
{{ #message }}
<p class='error-message'>{{message}}</p>
{{ /message }}
{{ /errors }}
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='link_to_support'>
<a href='http://support.signal.org/hc/articles/213134107' target='_blank'>
{{ learnMore }}
@ -514,7 +368,6 @@
<script type='text/javascript' src='../js/views/timestamp_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_detail_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
@ -528,12 +381,10 @@
<script type='text/javascript' src='../js/views/last_seen_indicator_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type="text/javascript" src='../js/views/identity_key_send_error_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/clear_data_view.js'></script>
<script type="text/javascript" src="views/whisper_view_test.js"></script>
<script type="text/javascript" src="views/group_update_view_test.js"></script>
<script type="text/javascript" src="views/message_view_test.js"></script>
<script type="text/javascript" src="views/attachment_view_test.js"></script>
<script type="text/javascript" src="views/timestamp_view_test.js"></script>
<script type="text/javascript" src="views/list_view_test.js"></script>

View File

@ -164,21 +164,21 @@
message = messages.add({ group_update: { left: 'Alice' } });
assert.equal(
message.getDescription(),
'Alice left the group.',
'Alice left the group',
'Notes one person leaving the group.'
);
message = messages.add({ group_update: { name: 'blerg' } });
assert.equal(
message.getDescription(),
"Updated the group. Title is now 'blerg'.",
"Title is now 'blerg'",
'Returns a single notice if only group_updates.name changes.'
);
message = messages.add({ group_update: { joined: ['Bob'] } });
assert.equal(
message.getDescription(),
'Updated the group. Bob joined the group.',
'Bob joined the group',
'Returns a single notice if only group_updates.joined changes.'
);
@ -187,7 +187,7 @@
});
assert.equal(
message.getDescription(),
'Updated the group. Bob, Alice, Eve joined the group.',
'Bob, Alice, Eve joined the group',
'Notes when >1 person joins the group.'
);
@ -196,7 +196,7 @@
});
assert.equal(
message.getDescription(),
"Updated the group. Title is now 'blerg'. Bob joined the group.",
"Title is now 'blerg', Bob joined the group",
'Notes when there are multiple changes to group_updates properties.'
);

View File

@ -278,6 +278,12 @@ describe('Message', () => {
return 'abc/abcdefg';
},
getRegionCode: () => 'US',
getAbsoluteAttachmentPath: () => 'some/path/on/disk',
makeObjectUrl: () => 'blob://FAKE',
revokeObjectUrl: () => null,
getImageDimensions: () => ({ height: 10, width: 15 }),
makeImageThumbnail: () => new Blob(),
makeVideoScreenshot: () => new Blob(),
};
const actual = await Message.upgradeSchema(input, context);
assert.deepEqual(actual, expected);

View File

@ -1,84 +0,0 @@
'use strict';
/* global window: false */
// Because we aren't hosting the Style Guide in Electron, we can't rely on preload.js
// to set things up for us. This gives us the minimum bar shims for everything it
// provdes.
//
// Remember, the idea here is just to enable visual testing, no full functionality. Most
// of thise can be very simple.
window.PROTO_ROOT = '/protos';
window.nodeSetImmediate = () => {};
window.libphonenumber = {
parse: number => ({
e164: number,
isValidNumber: true,
getCountryCode: () => '1',
getNationalNumber: () => number,
}),
isValidNumber: () => true,
getRegionCodeForNumber: () => '1',
format: number => number.e164,
PhoneNumberFormat: {},
};
window.Signal = {};
window.Signal.Backup = {};
window.Signal.Crypto = {};
window.Signal.Logs = {};
window.Signal.Migrations = {
getPlaceholderMigrations: () => [
{
migrate: (transaction, next) => {
console.log('migration version 1');
transaction.db.createObjectStore('conversations');
next();
},
version: 1,
},
{
migrate: (transaction, next) => {
console.log('migration version 2');
const messages = transaction.db.createObjectStore('messages');
messages.createIndex('expires_at', 'expireTimer', { unique: false });
next();
},
version: 2,
},
{
migrate: (transaction, next) => {
console.log('migration version 3');
transaction.db.createObjectStore('items');
next();
},
version: 3,
},
],
loadAttachmentData: attachment => Promise.resolve(attachment),
getAbsoluteAttachmentPath: path => path,
};
window.Signal.Components = {};
window.i18n = () => '';
// Ideally we don't need to add things here. We want to add them in StyleGuideUtil, which
// means that references to these things can't be early-bound, not capturing the direct
// reference to the function on file load.
window.Signal.Migrations.V17 = {};
window.Signal.OS = {};
window.Signal.Types = {};
window.Signal.Types.Attachment = {};
window.Signal.Types.Conversation = {};
window.Signal.Types.Errors = {};
window.Signal.Types.Message = {
initializeSchemaVersion: attributes => attributes,
};
window.Signal.Types.MIME = {};
window.Signal.Types.Settings = {};
window.Signal.Views = {};
window.Signal.Views.Initialization = {};
window.Signal.Workflow = {};

View File

@ -1,90 +0,0 @@
'use strict';
/* global window: false */
// Taken from background.html.
// Templates are here solely to support the Backbone views rendered in the Style Guide.
// Note: Any change here must be reflected in background.html to be reflected in the app
// and test/index.html to be reflected in the unit tests.
window.Whisper.View.Templates = {
hasRetry: `
{{ messageNotSent }} <span href='#' class='retry'>{{ resend }}</span>
`,
'some-failed': `
{{ someFailed }}
`,
keychange: `
<span class='content' dir='auto'>
<span class='shield icon'></span> {{ content }}
</span>
`,
'verified-change': `
<span class='content' dir='auto'>
<span class='{{ icon }} icon'></span> {{ content }}
</span>
`,
message: `
{{> avatar }}
<div class='bubble {{ avatar.color }}'>
<div class='sender' dir='auto'>
{{ sender }}
{{ #profileName }}
<span class='profileName'>{{ profileName }} </span>
{{ /profileName }}
</div>
<div class='tail-wrapper {{ innerBubbleClasses }}'>
<div class='inner-bubble'>
{{ #hasAttachments }}
<div class='attachments'></div>
{{ /hasAttachments }}
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'></div>
{{ /message }}
</div>
{{ /hasBody }}
</div>
</div>
<div class='meta'>
<span class='timestamp' data-timestamp={{ timestamp }}></span>
<span class='status hide'></span>
<span class='timer'></span>
</div>
{{ #hoverIcon }}
<div class='menu-container menu'>
<div class='menu-anchor'>
<span class='dots-horizontal-icon'></span>
<ul class='menu-list'>
<li class='reply'>{{ reply }}</li>
</ul>
</div>
</div>
{{ /hoverIcon }}
</div>
`,
hourglass: `
<span class='hourglass'><span class='sand'></span></span>
`,
expirationTimerUpdate: `
<span class='content'><span class='icon clock'></span> {{ content }}</span>
`,
'file-view': `
<div class='icon {{ mediaType }}'></div>
<div class='text'>
<div class='fileName' title='{{ altText }}'>
{{ fileName }}
</div>
<div class='fileSize'>{{ fileSize }}</div>
</div>
`,
'error-icon': `
<span class='error-icon'>
</span>
{{ #message }}
<span class='error-message'>{{message}}</span>
{{ /message }}
`,
};

View File

@ -5,6 +5,22 @@
'use strict';
describe('AttachmentView', () => {
var convo, message;
before(async () => {
await clearDatabase();
convo = new Whisper.Conversation({ id: 'foo' });
message = convo.messageCollection.add({
conversationId: convo.id,
body: 'hello world',
type: 'outgoing',
source: '+14158675309',
received_at: Date.now(),
});
await storage.put('number_id', '+18088888888.1');
});
describe('with arbitrary files', () => {
it('should render a file view', () => {
const attachment = {

View File

@ -1,90 +0,0 @@
describe('MessageView', function() {
var convo, message;
before(async () => {
await clearDatabase();
convo = new Whisper.Conversation({ id: 'foo' });
message = convo.messageCollection.add({
conversationId: convo.id,
body: 'hello world',
type: 'outgoing',
source: '+14158675309',
received_at: Date.now(),
});
await storage.put('number_id', '+18088888888.1');
});
it('should display the message text', function() {
var view = new Whisper.MessageView({ model: message }).render();
assert.match(view.$el.text(), /hello world/);
});
it('should auto-update the message text', function() {
var view = new Whisper.MessageView({ model: message }).render();
message.set('body', 'goodbye world');
assert.match(view.$el.html(), /goodbye world/);
});
it('should have a nice timestamp', function() {
var view = new Whisper.MessageView({ model: message });
message.set({ sent_at: Date.now() - 5000 });
view.render();
assert.match(view.$el.html(), /now/);
message.set({ sent_at: Date.now() - 60000 });
view.render();
assert.match(view.$el.html(), /min/);
message.set({ sent_at: Date.now() - 3600000 });
view.render();
assert.match(view.$el.html(), /hour/);
});
it('should not imply messages are from the future', function() {
var view = new Whisper.MessageView({ model: message });
message.set({ sent_at: Date.now() + 60000 });
view.render();
assert.match(view.$el.html(), /now/);
});
it('should go away when the model is destroyed', function() {
var view = new Whisper.MessageView({ model: message });
var div = $('<div>').append(view.$el);
message.destroy();
assert.strictEqual(div.find(view.$el).length, 0);
});
it('allows links', function() {
var url = 'http://example.com';
message.set('body', url);
var view = new Whisper.MessageView({ model: message });
view.render();
var link = view.$el.find('.body a');
assert.strictEqual(link.length, 1);
assert.strictEqual(link.text(), url);
assert.strictEqual(link.attr('href'), url);
});
it('disallows xss', function() {
var xss = '<script>alert("pwnd")</script>';
message.set('body', xss);
var view = new Whisper.MessageView({ model: message });
view.render();
assert.include(view.$el.text(), xss); // should appear as escaped text
assert.strictEqual(view.$el.find('script').length, 0); // should not appear as html
});
it('supports emoji', function() {
message.set('body', 'I \u2764\uFE0F emoji!');
var view = new Whisper.MessageView({ model: message });
view.render();
var img = view.$el.find('.content img');
assert.strictEqual(img.length, 1);
assert.strictEqual(
img.attr('src'),
'node_modules/emoji-datasource-apple/img/apple/64/2764-fe0f.png'
);
assert.strictEqual(img.attr('title'), ':heart:');
assert.strictEqual(img.attr('class'), 'emoji');
});
});

View File

@ -10,7 +10,6 @@ describe('ScrollDownButtonView', function() {
var view = new Whisper.ScrollDownButtonView({ count: 1 });
view.render();
assert.equal(view.count, 1);
assert.match(view.$el.html(), /new-messages/);
assert.match(view.$el.html(), /New message below/);
});
@ -19,7 +18,6 @@ describe('ScrollDownButtonView', function() {
view.render();
assert.equal(view.count, 2);
assert.match(view.$el.html(), /new-messages/);
assert.match(view.$el.html(), /New messages below/);
});
@ -27,9 +25,9 @@ describe('ScrollDownButtonView', function() {
var view = new Whisper.ScrollDownButtonView();
view.render();
assert.equal(view.count, 0);
assert.notMatch(view.$el.html(), /new-messages/);
assert.notMatch(view.$el.html(), /New message below/);
view.increment(1);
assert.equal(view.count, 1);
assert.match(view.$el.html(), /new-messages/);
assert.match(view.$el.html(), /New message below/);
});
});

61
ts/components/Intl.md Normal file
View File

@ -0,0 +1,61 @@
#### No replacements
```jsx
<Intl id="leftTheGroup" i18n={util.i18n} />
```
#### Single string replacement
```jsx
<Intl id="leftTheGroup" i18n={util.i18n} components={['Alice']} />
```
#### Single tag replacement
```jsx
<Intl
id="leftTheGroup"
i18n={util.i18n}
components={[
<button
key="external-2"
style={{ backgroundColor: 'blue', color: 'white' }}
>
Alice
</button>,
]}
/>
```
#### Multiple string replacement
```jsx
<Intl
id="changedSinceVerified"
i18n={util.i18n}
components={['Alice', 'Bob']}
/>
```
#### Multiple tag replacement
```jsx
<Intl
id="changedSinceVerified"
i18n={util.i18n}
components={[
<button
key="external-1"
style={{ backgroundColor: 'blue', color: 'white' }}
>
Alice
</button>,
<button
key="external-2"
style={{ backgroundColor: 'black', color: 'white' }}
>
Bob
</button>,
]}
/>
```

79
ts/components/Intl.tsx Normal file
View File

@ -0,0 +1,79 @@
import React from 'react';
import { Localizer, RenderTextCallback } from '../types/Util';
type FullJSX = Array<JSX.Element | string> | JSX.Element | string;
interface Props {
/** The translation string id */
id: string;
i18n: Localizer;
components?: Array<FullJSX>;
renderText?: RenderTextCallback;
}
export class Intl extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderText: ({ text }) => text,
};
public getComponent(index: number): FullJSX | null {
const { id, components } = this.props;
if (!components || !components.length || components.length <= index) {
// tslint:disable-next-line no-console
console.log(
`Error: Intl missing provided components for id ${id}, index ${index}`
);
return null;
}
return components[index];
}
public render() {
const { id, i18n, renderText } = this.props;
const text = i18n(id);
const results: Array<any> = [];
const FIND_REPLACEMENTS = /\$[^$]+\$/g;
// We have to do this, because renderText is not required in our Props object,
// but it is always provided via defaultProps.
if (!renderText) {
return;
}
let componentIndex = 0;
let key = 0;
let lastTextIndex = 0;
let match = FIND_REPLACEMENTS.exec(text);
if (!match) {
return renderText({ text, key: 0 });
}
while (match) {
if (lastTextIndex < match.index) {
const textWithNoReplacements = text.slice(lastTextIndex, match.index);
results.push(renderText({ text: textWithNoReplacements, key: key }));
key += 1;
}
results.push(this.getComponent(componentIndex));
componentIndex += 1;
// @ts-ignore
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(text);
}
if (lastTextIndex < text.length) {
results.push(renderText({ text: text.slice(lastTextIndex), key: key }));
key += 1;
}
return results;
}
}

View File

@ -3,17 +3,23 @@ const noop = () => {};
const messages = [
{
objectURL: 'https://placekitten.com/800/600',
objectURL: 'https://placekitten.com/799/600',
attachments: [{ contentType: 'image/jpeg' }],
},
{
objectURL: 'https://placekitten.com/900/600',
attachments: [{ contentType: 'image/jpeg' }],
},
// Unsupported image type
{
objectURL: 'foo.tif',
attachments: [{ contentType: 'image/tiff' }],
},
// Video
{
objectURL: util.mp4ObjectUrl,
attachments: [{ contentType: 'video/mp4' }],
},
{
objectURL: 'https://placekitten.com/980/800',
attachments: [{ contentType: 'image/jpeg' }],

View File

@ -1,32 +1,26 @@
#### With name and profile
#### Number, name and profile
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ContactName
i18n={util.i18n}
name="Someone 🔥 Somewhere"
phoneNumber="+12025550011"
profileName="🔥Flames🔥"
/>
</div>
<ContactName
i18n={util.i18n}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
/>
```
#### Profile, no name
#### Number and profile, no name
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ContactName
i18n={util.i18n}
phoneNumber="+12025550011"
profileName="🔥Flames🔥"
/>
</div>
<ContactName
i18n={util.i18n}
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
/>
```
#### No name, no profile
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ContactName i18n={util.i18n} phoneNumber="+12025550011" />
</div>
<ContactName i18n={util.i18n} phoneNumber="(202) 555-0011" />
```

View File

@ -9,23 +9,27 @@ interface Props {
name?: string;
profileName?: string;
i18n: Localizer;
module?: string;
}
export class ContactName extends React.Component<Props> {
public render() {
const { phoneNumber, name, profileName, i18n } = this.props;
const { phoneNumber, name, profileName, i18n, module } = this.props;
const prefix = module ? module : 'module-contact-name';
const title = name ? name : phoneNumber;
const profileElement =
profileName && !name ? (
<span className="profile-name">
~<Emojify text={profileName} i18n={i18n} />
</span>
) : null;
const shouldShowProfile = Boolean(profileName && !name);
const profileElement = shouldShowProfile ? (
<span className={`${prefix}__profile-name`}>
~<Emojify text={profileName || ''} i18n={i18n} />
</span>
) : null;
return (
<span>
<Emojify text={title} i18n={i18n} /> {profileElement}
<span className={prefix}>
<Emojify text={title} i18n={i18n} />
{shouldShowProfile ? ' ' : null}
{profileElement}
</span>
);
}

View File

@ -0,0 +1,139 @@
### Name variations, 1:1 conversation
Note the five items in gear menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.
#### With name and profile, verified
```jsx
<ConversationHeader
i18n={util.i18n}
color="red"
isVerified={true}
avatarPath={util.gifObjectUrl}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0001"
id="1"
profileName="🔥Flames🔥"
onSetDisappearingMessages={seconds =>
console.log('onSetDisappearingMessages', seconds)
}
onDeleteMessages={() => console.log('onDeleteMessages')}
onResetSession={() => console.log('onResetSession')}
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
/>
```
#### With name, not verified, no avatar
```jsx
<ConversationHeader
i18n={util.i18n}
color="blue"
isVerified={false}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0002"
id="2"
/>
```
#### Profile, no name
```jsx
<ConversationHeader
i18n={util.i18n}
color="teal"
isVerified={false}
phoneNumber="(202) 555-0003"
id="3"
profileName="🔥Flames🔥"
/>
```
#### No name, no profile, no color
```jsx
<ConversationHeader i18n={util.i18n} phoneNumber="(202) 555-0011" id="11" />
```
### With back button
```jsx
<ConversationHeader
showBackButton={true}
color="deep_orange"
i18n={util.i18n}
phoneNumber="(202) 555-0004"
id="4"
/>
```
### Disappearing messages set
```jsx
<ConversationHeader
color="indigo"
i18n={util.i18n}
phoneNumber="(202) 555-0005"
id="5"
expirationSettingName="10 seconds"
timerOptions={[
{
name: 'off',
value: 0,
},
{
name: '10 seconds',
value: 10,
},
]}
onSetDisappearingMessages={seconds =>
console.log('onSetDisappearingMessages', seconds)
}
onDeleteMessages={() => console.log('onDeleteMessages')}
onResetSession={() => console.log('onResetSession')}
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
/>
```
### In a group
Note that the menu should includes 'Show Members' instead of 'Show Safety Number'
```jsx
<ConversationHeader
i18n={util.i18n}
color="green"
phoneNumber="(202) 555-0006"
id="6"
isGroup={true}
onSetDisappearingMessages={seconds =>
console.log('onSetDisappearingMessages', seconds)
}
onDeleteMessages={() => console.log('onDeleteMessages')}
onResetSession={() => console.log('onResetSession')}
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
/>
```
### In chat with yourself
Note that the menu should not have a 'Show Safety Number' entry.
```jsx
<ConversationHeader
color="cyan"
i18n={util.i18n}
phoneNumber="(202) 555-0007"
id="7"
isMe={true}
/>
```

View File

@ -0,0 +1,253 @@
import React from 'react';
import classNames from 'classnames';
import { Emojify } from './Emojify';
import { Localizer } from '../../types/Util';
import {
ContextMenu,
ContextMenuTrigger,
MenuItem,
SubMenu,
} from 'react-contextmenu';
interface TimerOption {
name: string;
value: number;
}
interface Trigger {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
}
interface Props {
i18n: Localizer;
isVerified: boolean;
name?: string;
id: string;
phoneNumber: string;
profileName?: string;
color: string;
avatarPath?: string;
isMe: boolean;
isGroup: boolean;
expirationSettingName?: string;
showBackButton: boolean;
timerOptions: Array<TimerOption>;
onSetDisappearingMessages: (seconds: number) => void;
onDeleteMessages: () => void;
onResetSession: () => void;
onShowSafetyNumber: () => void;
onShowAllMedia: () => void;
onShowGroupMembers: () => void;
onGoBack: () => void;
}
function getInitial(name: string): string {
return name.trim()[0] || '#';
}
export class ConversationHeader extends React.Component<Props> {
public captureMenuTriggerBound: (trigger: any) => void;
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
public menuTriggerRef: Trigger | null;
public constructor(props: Props) {
super(props);
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
this.showMenuBound = this.showMenu.bind(this);
this.menuTriggerRef = null;
}
public captureMenuTrigger(triggerRef: Trigger) {
this.menuTriggerRef = triggerRef;
}
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
}
}
public renderBackButton() {
const { onGoBack, showBackButton } = this.props;
if (!showBackButton) {
return null;
}
return (
<div
onClick={onGoBack}
role="button"
className="module-conversation-header__back-icon"
/>
);
}
public renderTitle() {
const { name, phoneNumber, i18n, profileName, isVerified } = this.props;
return (
<div className="module-conversation-header__title">
{name ? <Emojify text={name} i18n={i18n} /> : null}
{name && phoneNumber ? ' · ' : null}
{phoneNumber ? phoneNumber : null}{' '}
{profileName && !name ? (
<span className="module-conversation-header__title__profile-name">
<Emojify text={profileName} i18n={i18n} />
</span>
) : null}
{isVerified ? ' · ' : null}
{isVerified ? (
<span>
<span className="module-conversation-header__title__verified-icon" />
{i18n('verified')}
</span>
) : null}
</div>
);
}
public renderAvatar() {
const {
avatarPath,
color,
i18n,
name,
phoneNumber,
profileName,
} = this.props;
if (!avatarPath) {
const initial = getInitial(name || '');
return (
<div
className={classNames(
'module-conversation-header___avatar',
'module-conversation-header___default-avatar',
`module-conversation-header___default-avatar--${color}`
)}
>
{initial}
</div>
);
}
const title = `${name || phoneNumber}${
!name && profileName ? ` ~${profileName}` : ''
}`;
return (
<img
className="module-conversation-header___avatar"
alt={i18n('contactAvatarAlt', [title])}
src={avatarPath}
/>
);
}
public renderExpirationLength() {
const { expirationSettingName } = this.props;
if (!expirationSettingName) {
return null;
}
return (
<div className="module-conversation-header__expiration">
<div className="module-conversation-header__expiration__clock-icon" />
<div className="module-conversation-header__expiration__setting">
{expirationSettingName}
</div>
</div>
);
}
public renderGear(triggerId: string) {
const { showBackButton } = this.props;
if (showBackButton) {
return null;
}
return (
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
<div
role="button"
onClick={this.showMenuBound}
className="module-conversation-header__gear-icon"
/>
</ContextMenuTrigger>
);
}
/* tslint:disable:jsx-no-lambda react-this-binding-issue */
public renderMenu(triggerId: string) {
const {
i18n,
isMe,
isGroup,
onDeleteMessages,
onResetSession,
onSetDisappearingMessages,
onShowAllMedia,
onShowGroupMembers,
onShowSafetyNumber,
timerOptions,
} = this.props;
const title = i18n('disappearingMessages') as any;
return (
<ContextMenu id={triggerId}>
<SubMenu title={title}>
{(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}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
</ContextMenu>
);
}
/* tslint:enable */
public render() {
const { id } = this.props;
return (
<div className="module-conversation-header">
{this.renderBackButton()}
{this.renderAvatar()}
{this.renderTitle()}
{this.renderExpirationLength()}
{this.renderGear(id)}
{this.renderMenu(id)}
</div>
);
}
}

View File

@ -1,45 +0,0 @@
#### With name and profile, verified
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ConversationTitle
i18n={util.i18n}
isVerified
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
/>
</div>
```
#### With name, not verified
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ConversationTitle
i18n={util.i18n}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
/>
</div>
```
#### Profile, no name
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ConversationTitle
i18n={util.i18n}
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
/>
</div>
```
#### No name, no profile
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ConversationTitle i18n={util.i18n} phoneNumber="(202) 555-0011" />
</div>
```

View File

@ -1,42 +0,0 @@
import React from 'react';
import { Emojify } from './Emojify';
import { Localizer } from '../../types/Util';
interface Props {
i18n: Localizer;
isVerified: boolean;
name?: string;
phoneNumber: string;
profileName?: string;
}
export class ConversationTitle extends React.Component<Props> {
public render() {
const { name, phoneNumber, i18n, profileName, isVerified } = this.props;
return (
<span className="conversation-title">
{name ? (
<span className="conversation-name" dir="auto">
<Emojify text={name} i18n={i18n} />
</span>
) : null}
{phoneNumber ? (
<span className="conversation-number">{phoneNumber}</span>
) : null}{' '}
{profileName && !name ? (
<span className="profileName">
<Emojify text={profileName} i18n={i18n} />
</span>
) : null}
{isVerified ? (
<span className="verified">
<span className="verified-icon" />
{i18n('verified')}
</span>
) : null}
</span>
);
}
}

View File

@ -3,516 +3,533 @@
#### Including all data types
```jsx
const contacts = [
{
name: {
displayName: 'Someone Somewhere',
const contact = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
],
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
onClick: () => console.log('onClick'),
onSendMessage: () => console.log('onSendMessage'),
hasSignalAccount: true,
};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### Really long long data
#### Really long data
```
const contacts = [
{
name: {
displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips',
const contact = {
name: {
displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips',
},
number: [
{
value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000',
type: 1,
},
number: [
{
value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000',
type: 1,
},
],
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
hasSignalAccount: true,
};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
<li><Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
contact={contact}/></li>
<li><Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
contact={contact}/></li>
</util.ConversationContext>;
```
#### In group conversation
```jsx
const contacts = [
{
name: {
displayName: 'Someone Somewhere',
const contact = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
],
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
hasSignalAccount: true,
};
<util.ConversationContext theme={util.theme} type="group">
<Message
color="green"
conversationType="group"
authorName="Mr. Fire"
authorAvatarPath={util.gifObjectUrl}
direction="incoming"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
color="green"
direction="incoming"
authorName="Mr. Fire"
conversationType="group"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
conversationType="group"
authorName="Mr. Fire"
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<li>
<Message
authorColor="green"
conversationType="group"
authorName="Mr. Fire"
authorAvatarPath={util.gifObjectUrl}
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
authorName="Mr. Fire"
conversationType="group"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
conversationType="group"
authorName="Mr. Fire"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### If contact has no signal account
```jsx
const contacts = [
{
name: {
displayName: 'Someone Somewhere',
const contact = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
],
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
hasSignalAccount: false,
};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### With organization name instead of name
```jsx
const contacts = [
{
organization: 'United Somewheres, Inc.',
email: [
{
value: 'someone@somewheres.com',
type: 2,
},
],
const contact = {
organization: 'United Somewheres, Inc.',
email: [
{
value: 'someone@somewheres.com',
type: 2,
},
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
hasSignalAccount: false,
};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### No displayName or organization
```jsx
const contacts = [
{
name: {
givenName: 'Someone',
const contact = {
name: {
givenName: 'Someone',
},
number: [
{
value: '(202) 555-1000',
type: 1,
},
number: [
{
value: '+12025551000',
type: 1,
},
],
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
hasSignalAccount: false,
};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### Default avatar
```jsx
const contacts = [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: util.CONTACTS[0].id,
type: 1,
},
],
const contact = {
name: {
displayName: 'Someone Somewhere',
},
];
number: [
{
value: '(202) 555-1001',
type: 1,
},
],
hasSignalAccount: true,
};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### Empty contact
```jsx
const contacts = [{}];
const contact = {};
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contact}
/>
</li>
<li>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contact}
/>
</li>
</util.ConversationContext>;
```
#### Contact with caption (cannot currently be sent)
```jsx
const contacts = [
{
name: {
displayName: 'Someone Somewhere',
const contactWithAccount = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
],
],
avatar: {
avatar: {
avatar: {
path: util.gifObjectUrl,
},
path: util.gifObjectUrl,
},
},
];
hasSignalAccount: true,
};
const contactWithoutAccount = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
],
avatar: {
avatar: {
path: util.gifObjectUrl,
},
},
hasSignalAccount: false,
};
<util.ConversationContext theme={util.theme}>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<li>
<Message
text="I want to introduce you to Someone..."
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contactWithAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contactWithAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
authorColor="green"
direction="incoming"
i18n={util.i18n}
contact={contactWithAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
status="delivered"
i18n={util.i18n}
contact={contactWithAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contactWithoutAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contactWithoutAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
authorColor="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contact={contactWithoutAccount}
/>
</li>
<li>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contact={contactWithoutAccount}
/>
</li>
</util.ConversationContext>;
```

View File

@ -12,8 +12,7 @@ interface Props {
isIncoming: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
onSendMessage?: () => void;
onClickContact?: () => void;
onClick?: () => void;
}
export class EmbeddedContact extends React.Component<Props> {
@ -22,7 +21,7 @@ export class EmbeddedContact extends React.Component<Props> {
contact,
i18n,
isIncoming,
onClickContact,
onClick,
withContentAbove,
withContentBelow,
} = this.props;
@ -40,7 +39,7 @@ export class EmbeddedContact extends React.Component<Props> {
: null
)}
role="button"
onClick={onClickContact}
onClick={onClick}
>
{renderAvatar({ contact, i18n, module })}
<div className="module-embedded-contact__text-container">

View File

@ -34,10 +34,13 @@ function getImageTag({
const title = getTitle(result.value);
return (
// tslint:disable-next-line react-a11y-img-has-alt
<img
key={key}
src={img.path}
alt={i18n('emojiAlt', [title || ''])}
// We can't use alt or it will be what is captured when a user copies message
// contents ("Emoji of ':1'"). Instead, we want the title to be copied (':+1:').
aria-label={i18n('emojiAlt', [title || ''])}
className={classNames('emoji', sizeClass)}
data-codepoints={img.full_idx}
title={`:${title}:`}

View File

@ -0,0 +1,193 @@
### Countdown at different rates
```jsx
<util.ConversationContext theme={util.theme}>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="10 second timer"
i18n={util.i18n}
expirationLength={10 * 1000}
expirationTimestamp={Date.now() + 10 * 1000}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="cyan"
text="30 second timer"
i18n={util.i18n}
expirationLength={30 * 1000}
expirationTimestamp={Date.now() + 30 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="1 minute timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="5 minute timer"
i18n={util.i18n}
expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000}
/>
</li>
</util.ConversationContext>
```
### Timer calculations
```jsx
<util.ConversationContext theme={util.theme}>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="Full timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 60 * 1000}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="Full timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 60 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="55 timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="55 timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="30 timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 30 * 1000}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="30 timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 30 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="5 timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 5 * 1000}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="5 timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 5 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="Expired timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now()}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="Expired timer"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now()}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="Expiration is too far away"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 120 * 1000}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="Expiration is too far away"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 120 * 1000}
/>
</li>
<li>
<Message
authorColor="cyan"
direction="incoming"
text="Already expired"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() - 20 * 1000}
/>
</li>
<li>
<Message
direction="outgoing"
status="delivered"
text="Already expired"
i18n={util.i18n}
expirationLength={60 * 1000}
expirationTimestamp={Date.now() - 20 * 1000}
/>
</li>
</util.ConversationContext>
```

View File

@ -0,0 +1,86 @@
import React from 'react';
import classNames from 'classnames';
import { padStart } from 'lodash';
interface Props {
withImageNoCaption: boolean;
expirationLength: number;
expirationTimestamp: number;
direction: 'incoming' | 'outgoing';
}
export class ExpireTimer extends React.Component<Props> {
private interval: any;
constructor(props: Props) {
super(props);
this.interval = null;
}
public componentDidMount() {
const { expirationLength } = this.props;
const increment = getIncrement(expirationLength);
const updateFrequency = Math.max(increment, 500);
const update = () => {
this.setState({
lastUpdated: Date.now(),
});
};
this.interval = setInterval(update, updateFrequency);
}
public componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
public render() {
const {
direction,
expirationLength,
expirationTimestamp,
withImageNoCaption,
} = this.props;
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
return (
<div
className={classNames(
'module-expire-timer',
`module-expire-timer--${bucket}`,
`module-expire-timer--${direction}`,
withImageNoCaption
? 'module-expire-timer--with-image-no-caption'
: null
)}
/>
);
}
}
export function getIncrement(length: number): number {
if (length < 0) {
return 1000;
}
return Math.ceil(length / 12);
}
function getTimerBucket(expiration: number, length: number): string {
const delta = expiration - Date.now();
if (delta < 0) {
return '00';
}
if (delta > length) {
return '60';
}
const bucket = Math.round(delta / length * 12);
return padStart(String(bucket * 5), 2, '0');
}

View File

@ -0,0 +1,171 @@
### Three changes, all types
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
{
type: 'name',
newName: 'New Group Name',
},
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Joined group
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
]}
i18n={util.i18n}
/>
<GroupNotification
changes={[
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Left group
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
{
phoneNumber: '(202) 555-1001',
profileName: 'Mrs. Ice',
},
{
phoneNumber: '(202) 555-1002',
name: 'Ms. Earth',
},
],
},
]}
i18n={util.i18n}
/>
<GroupNotification
changes={[
{
type: 'remove',
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
<GroupNotification
changes={[
{
type: 'remove',
isMe: true,
contacts: [
{
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
},
],
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Title changed
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'name',
newName: 'New Group Name',
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Generic group update
```js
<util.ConversationContext theme={util.theme}>
<GroupNotification
changes={[
{
type: 'general',
},
]}
i18n={util.i18n}
/>
</util.ConversationContext>
```

View File

@ -0,0 +1,109 @@
import React from 'react';
// import classNames from 'classnames';
import { compact, flatten } from 'lodash';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { Localizer } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
interface Contact {
phoneNumber: string;
profileName?: string;
name?: string;
}
interface Change {
type: 'add' | 'remove' | 'name' | 'general';
isMe: boolean;
newName?: string;
contacts?: Array<Contact>;
}
interface Props {
changes: Array<Change>;
i18n: Localizer;
}
export class GroupNotification extends React.Component<Props> {
public renderChange(change: Change) {
const { isMe, contacts, type, newName } = change;
const { i18n } = this.props;
const people = compact(
flatten(
(contacts || []).map((contact, index) => {
const element = (
<span
key={`external-${contact.phoneNumber}`}
className="module-group-notification__contact"
>
<ContactName
i18n={i18n}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
name={contact.name}
/>
</span>
);
return [index > 0 ? ', ' : null, element];
})
)
);
switch (type) {
case 'name':
return i18n('titleIsNow', [newName || '']);
case 'add':
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
return (
<Intl
i18n={i18n}
id={
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'
}
components={[people]}
/>
);
case 'remove':
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
if (isMe) {
return i18n('youLeftTheGroup');
}
return (
<Intl
i18n={i18n}
id={contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'}
components={[people]}
/>
);
case 'general':
return i18n('updatedTheGroup');
default:
throw missingCaseError(type);
}
}
public render() {
const { changes } = this.props;
return (
<div className="module-group-notification">
{(changes || []).map(change => (
<div className="module-group-notification__change">
{this.renderChange(change)}
</div>
))}
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,28 @@
import React from 'react';
import classNames from 'classnames';
import moment from 'moment';
import { padStart } from 'lodash';
import { formatRelativeTime } from '../../util/formatRelativeTime';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
import { MessageBody } from './MessageBody';
import { Emojify } from './Emojify';
import { ExpireTimer, getIncrement } from './ExpireTimer';
import { Timestamp } from './Timestamp';
import { ContactName } from './ContactName';
import { Quote, QuotedAttachment } from './Quote';
import { EmbeddedContact } from './EmbeddedContact';
import { Contact } from '../../types/Contact';
import { Localizer } from '../../types/Util';
import { Color, Localizer } from '../../types/Util';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import * as MIME from '../../../ts/types/MIME';
interface Trigger {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
}
interface Attachment {
contentType: MIME.MIMEType;
fileName: string;
@ -26,50 +31,69 @@ interface Attachment {
/** For messages not already on disk, this will be a data url */
url: string;
fileSize?: string;
width: number;
height: number;
screenshot?: {
height: number;
width: number;
url: string;
contentType: MIME.MIMEType;
};
thumbnail?: {
height: number;
width: number;
url: string;
contentType: MIME.MIMEType;
};
}
interface Props {
export interface Props {
disableMenu?: boolean;
text?: string;
id?: string;
collapseMetadata?: boolean;
direction: 'incoming' | 'outgoing';
timestamp: number;
status?: 'sending' | 'sent' | 'delivered' | 'read';
contacts?: Array<Contact>;
color:
| 'gray'
| 'blue'
| 'cyan'
| 'deep-orange'
| 'green'
| 'indigo'
| 'pink'
| 'purple'
| 'red'
| 'teal';
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
// What if changed this over to a single contact like quote, and put the events on it?
contact?: Contact & {
hasSignalAccount: boolean;
onSendMessage?: () => void;
onClick?: () => void;
};
i18n: Localizer;
authorName?: string;
authorProfileName?: string;
/** Note: this should be formatted for display */
authorPhoneNumber?: string;
authorPhoneNumber: string;
authorColor: Color;
conversationType: 'group' | 'direct';
attachment?: Attachment;
quote?: {
text: string;
attachments: Array<QuotedAttachment>;
attachment?: QuotedAttachment;
isFromMe: boolean;
authorName?: string;
authorPhoneNumber?: string;
authorPhoneNumber: string;
authorProfileName?: string;
authorName?: string;
authorColor: Color;
onClick?: () => void;
};
authorAvatarPath?: string;
contactHasSignalAccount: boolean;
expirationLength?: number;
expirationTimestamp?: number;
onClickQuote?: () => void;
onSendMessageToContact?: () => void;
onClickContact?: () => void;
onClickAttachment?: () => void;
onReply?: () => void;
onRetrySend?: () => void;
onDownload?: () => void;
onDelete?: () => void;
onShowDetail: () => void;
}
interface State {
expiring: boolean;
expired: boolean;
imageBroken: boolean;
}
function isImage(attachment?: Attachment) {
@ -80,6 +104,10 @@ function isImage(attachment?: Attachment) {
);
}
function hasImage(attachment?: Attachment) {
return attachment && attachment.url;
}
function isVideo(attachment?: Attachment) {
return (
attachment &&
@ -88,24 +116,18 @@ function isVideo(attachment?: Attachment) {
);
}
function hasVideoScreenshot(attachment?: Attachment) {
return attachment && attachment.screenshot && attachment.screenshot.url;
}
function isAudio(attachment?: Attachment) {
return (
attachment && attachment.contentType && MIME.isAudio(attachment.contentType)
);
}
function getTimerBucket(expiration: number, length: number): string {
const delta = expiration - Date.now();
if (delta < 0) {
return '00';
}
if (delta > length) {
return '60';
}
const increment = Math.round(delta / length * 12);
return padStart(String(increment * 5), 2, '0');
function getInitial(name: string): string {
return name.trim()[0] || '#';
}
function getExtension({
@ -131,58 +153,118 @@ function getExtension({
return null;
}
export class Message extends React.Component<Props> {
public renderTimer() {
const {
attachment,
direction,
expirationLength,
expirationTimestamp,
text,
} = this.props;
const MINIMUM_IMG_HEIGHT = 150;
const MAXIMUM_IMG_HEIGHT = 300;
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
if (!expirationLength || !expirationTimestamp) {
return null;
export class Message extends React.Component<Props, State> {
public captureMenuTriggerBound: (trigger: any) => void;
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
public handleImageErrorBound: () => void;
public menuTriggerRef: Trigger | null;
public expirationCheckInterval: any;
public expiredTimeout: any;
public constructor(props: Props) {
super(props);
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
this.showMenuBound = this.showMenu.bind(this);
this.handleImageErrorBound = this.handleImageError.bind(this);
this.menuTriggerRef = null;
this.expirationCheckInterval = null;
this.expiredTimeout = null;
this.state = {
expiring: false,
expired: false,
imageBroken: false,
};
}
public componentDidMount() {
const { expirationLength } = this.props;
if (!expirationLength) {
return;
}
const withImageNoCaption = !text && isImage(attachment);
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
const increment = getIncrement(expirationLength);
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
return (
<div
className={classNames(
'module-message__metadata__timer',
`module-message__metadata__timer--${bucket}`,
`module-message__metadata__timer--${direction}`,
withImageNoCaption
? 'module-message__metadata__timer--with-image-no-caption'
: null
)}
/>
);
this.checkExpired();
this.expirationCheckInterval = setInterval(() => {
this.checkExpired();
}, checkFrequency);
}
public componentWillUnmount() {
if (this.expirationCheckInterval) {
clearInterval(this.expirationCheckInterval);
}
if (this.expiredTimeout) {
clearTimeout(this.expiredTimeout);
}
}
public checkExpired() {
const now = Date.now();
const { expirationTimestamp, expirationLength } = this.props;
if (!expirationTimestamp || !expirationLength) {
return;
}
if (now >= expirationTimestamp) {
this.setState({
expiring: true,
});
const setExpired = () => {
this.setState({
expired: true,
});
};
this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY);
}
}
public handleImageError() {
// tslint:disable-next-line no-console
console.log('Message: Image failed to load; failing over to placeholder');
this.setState({
imageBroken: true,
});
}
public renderMetadata() {
const {
attachment,
collapseMetadata,
color,
direction,
expirationLength,
expirationTimestamp,
i18n,
status,
timestamp,
text,
attachment,
timestamp,
} = this.props;
const { imageBroken } = this.state;
if (collapseMetadata) {
return null;
}
// We're not showing metadata on top of videos since they still have native controls
if (!text && isVideo(attachment)) {
return null;
}
const withImageNoCaption = !text && isImage(attachment);
const withImageNoCaption = Boolean(
!text &&
!imageBroken &&
((isImage(attachment) && hasImage(attachment)) ||
(isVideo(attachment) && hasVideoScreenshot(attachment)))
);
const showError = status === 'error' && direction === 'outgoing';
return (
<div
@ -193,33 +275,43 @@ export class Message extends React.Component<Props> {
: null
)}
>
<span
className={classNames(
'module-message__metadata__date',
`module-message__metadata__date--${direction}`,
withImageNoCaption
? 'module-message__metadata__date--with-image-no-caption'
: null
)}
title={moment(timestamp).format('llll')}
>
{formatRelativeTime(timestamp, { i18n, extended: true })}
</span>
{this.renderTimer()}
{showError ? (
<span
className={classNames(
'module-message__metadata__date',
`module-message__metadata__date--${direction}`,
withImageNoCaption
? 'module-message__metadata__date--with-image-no-caption'
: null
)}
>
{i18n('sendFailed')}
</span>
) : (
<Timestamp
i18n={i18n}
timestamp={timestamp}
direction={direction}
withImageNoCaption={withImageNoCaption}
module="module-message__metadata__date"
/>
)}
{expirationLength && expirationTimestamp ? (
<ExpireTimer
direction={direction}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
/>
) : null}
<span className="module-message__metadata__spacer" />
{direction === 'outgoing' ? (
{direction === 'outgoing' && status !== 'error' ? (
<div
className={classNames(
'module-message__metadata__status-icon',
`module-message__metadata__status-icon-${status}`,
status === 'read'
? `module-message__metadata__status-icon-${color}`
: null,
`module-message__metadata__status-icon--${status}`,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null,
withImageNoCaption && status === 'read'
? 'module-message__metadata__status-icon--read-with-image-no-caption'
: null
)}
/>
@ -231,11 +323,11 @@ export class Message extends React.Component<Props> {
public renderAuthor() {
const {
authorName,
authorPhoneNumber,
authorProfileName,
conversationType,
direction,
i18n,
authorPhoneNumber,
authorProfileName,
} = this.props;
const title = authorName ? authorName : authorPhoneNumber;
@ -244,21 +336,20 @@ export class Message extends React.Component<Props> {
return null;
}
const profileElement =
authorProfileName && !authorName ? (
<span className="module-message__author__profile-name">
~<Emojify text={authorProfileName} i18n={i18n} />
</span>
) : null;
return (
<div className="module-message__author">
<Emojify text={title} i18n={i18n} /> {profileElement}
<ContactName
phoneNumber={authorPhoneNumber}
name={authorName}
profileName={authorProfileName}
module="module-message__author"
i18n={i18n}
/>
</div>
);
}
// tslint:disable-next-line max-func-body-length
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
public renderAttachment() {
const {
i18n,
@ -270,6 +361,7 @@ export class Message extends React.Component<Props> {
quote,
onClickAttachment,
} = this.props;
const { imageBroken } = this.state;
if (!attachment) {
return null;
@ -282,9 +374,30 @@ export class Message extends React.Component<Props> {
quote || (conversationType === 'group' && direction === 'incoming');
if (isImage(attachment)) {
if (imageBroken || !attachment.url) {
return (
<div
className={classNames(
'module-message__broken-image',
`module-message__broken-image--${direction}`
)}
>
{i18n('imageFailedToLoad')}
</div>
);
}
// Calculating height to prevent reflow when image loads
const height = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0);
return (
<div className="module-message__attachment-container">
<div
onClick={onClickAttachment}
role="button"
className="module-message__attachment-container"
>
<img
onError={this.handleImageErrorBound}
className={classNames(
'module-message__img-attachment',
withCaption
@ -294,9 +407,9 @@ export class Message extends React.Component<Props> {
? 'module-message__img-attachment--with-content-above'
: null
)}
height={Math.min(MAXIMUM_IMG_HEIGHT, height)}
src={attachment.url}
alt={i18n('imageAttachmentAlt')}
onClick={onClickAttachment}
/>
{!withCaption && !collapseMetadata ? (
<div className="module-message__img-overlay" />
@ -304,21 +417,53 @@ export class Message extends React.Component<Props> {
</div>
);
} else if (isVideo(attachment)) {
const { screenshot } = attachment;
if (imageBroken || !screenshot || !screenshot.url) {
return (
<div
role="button"
onClick={onClickAttachment}
className={classNames(
'module-message__broken-video-screenshot',
`module-message__broken-video-screenshot--${direction}`
)}
>
{i18n('videoScreenshotFailedToLoad')}
</div>
);
}
// Calculating height to prevent reflow when image loads
const height = Math.max(MINIMUM_IMG_HEIGHT, screenshot.height || 0);
return (
<video
controls={true}
className={classNames(
'module-message__img-attachment',
withCaption
? 'module-message__img-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__img-attachment--with-content-above'
: null
)}
<div
onClick={onClickAttachment}
role="button"
className="module-message__attachment-container"
>
<source src={attachment.url} />
</video>
<img
onError={this.handleImageErrorBound}
className={classNames(
'module-message__img-attachment',
withCaption
? 'module-message__img-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__img-attachment--with-content-above'
: null
)}
alt={i18n('videoAttachmentAlt')}
height={Math.min(MAXIMUM_IMG_HEIGHT, height)}
src={screenshot.url}
/>
{!withCaption && !collapseMetadata ? (
<div className="module-message__img-overlay" />
) : null}
<div className="module-message__video-overlay__circle">
<div className="module-message__video-overlay__play-icon" />
</div>
</div>
);
} else if (isAudio(attachment)) {
return (
@ -384,38 +529,26 @@ export class Message extends React.Component<Props> {
}
public renderQuote() {
const {
color,
conversationType,
direction,
i18n,
onClickQuote,
quote,
} = this.props;
const { conversationType, direction, i18n, quote } = this.props;
if (!quote) {
return null;
}
const authorTitle = quote.authorName
? quote.authorName
: quote.authorPhoneNumber;
const authorProfileName = !quote.authorName
? quote.authorProfileName
: undefined;
const withContentAbove =
conversationType === 'group' && direction === 'incoming';
return (
<Quote
i18n={i18n}
onClick={onClickQuote}
color={color}
onClick={quote.onClick}
text={quote.text}
attachments={quote.attachments}
attachment={quote.attachment}
isIncoming={direction === 'incoming'}
authorTitle={authorTitle || ''}
authorProfileName={authorProfileName}
authorPhoneNumber={quote.authorPhoneNumber}
authorProfileName={quote.authorProfileName}
authorName={quote.authorName}
authorColor={quote.authorColor}
isFromMe={quote.isFromMe}
withContentAbove={withContentAbove}
/>
@ -425,18 +558,13 @@ export class Message extends React.Component<Props> {
public renderEmbeddedContact() {
const {
collapseMetadata,
contactHasSignalAccount,
contacts,
contact,
conversationType,
direction,
i18n,
onClickContact,
onSendMessageToContact,
text,
} = this.props;
const first = contacts && contacts[0];
if (!first) {
if (!contact) {
return null;
}
@ -447,12 +575,11 @@ export class Message extends React.Component<Props> {
return (
<EmbeddedContact
contact={first}
hasSignalAccount={contactHasSignalAccount}
contact={contact}
hasSignalAccount={contact.hasSignalAccount}
isIncoming={direction === 'incoming'}
i18n={i18n}
onSendMessage={onSendMessageToContact}
onClickContact={onClickContact}
onClick={contact.onClick}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
/>
@ -460,22 +587,15 @@ export class Message extends React.Component<Props> {
}
public renderSendMessageButton() {
const {
contactHasSignalAccount,
contacts,
i18n,
onSendMessageToContact,
} = this.props;
const first = contacts && contacts[0];
if (!first || !contactHasSignalAccount) {
const { contact, i18n } = this.props;
if (!contact || !contact.hasSignalAccount) {
return null;
}
return (
<div
role="button"
onClick={onSendMessageToContact}
onClick={contact.onSendMessage}
className="module-message__send-message-button"
>
{i18n('sendMessageToContact')}
@ -489,8 +609,8 @@ export class Message extends React.Component<Props> {
authorPhoneNumber,
authorProfileName,
authorAvatarPath,
authorColor,
collapseMetadata,
color,
conversationType,
direction,
i18n,
@ -509,14 +629,18 @@ export class Message extends React.Component<Props> {
}
if (!authorAvatarPath) {
const label = authorName ? getInitial(authorName) : '#';
return (
<div
className={classNames(
'module-message__author-default-avatar',
`module-message__author-default-avatar--${color}`
`module-message__author-default-avatar--${authorColor}`
)}
>
<div className="module-message__author-default-avatar__label">#</div>
<div className="module-message__author-default-avatar__label">
{label}
</div>
</div>
);
}
@ -529,9 +653,14 @@ export class Message extends React.Component<Props> {
}
public renderText() {
const { text, i18n, direction } = this.props;
const { text, i18n, direction, status } = this.props;
if (!text) {
const contents =
direction === 'incoming' && status === 'error'
? i18n('incomingError')
: text;
if (!contents) {
return null;
}
@ -539,38 +668,162 @@ export class Message extends React.Component<Props> {
<div
className={classNames(
'module-message__text',
`module-message__text--${direction}`
`module-message__text--${direction}`,
status === 'error' && direction === 'incoming'
? 'module-message__text--error'
: null
)}
>
<MessageBody text={text || ''} i18n={i18n} />
<MessageBody text={contents || ''} i18n={i18n} />
</div>
);
}
public renderError(isCorrectSide: boolean) {
const { status, direction } = this.props;
if (!isCorrectSide || status !== 'error') {
return null;
}
return (
<div className="module-message__error-container">
<div
className={classNames(
'module-message__error',
`module-message__error--${direction}`
)}
/>
</div>
);
}
public captureMenuTrigger(triggerRef: Trigger) {
this.menuTriggerRef = triggerRef;
}
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
}
}
public renderMenu(isCorrectSide: boolean, triggerId: string) {
const {
attachment,
direction,
disableMenu,
onDownload,
onReply,
} = this.props;
if (!isCorrectSide || disableMenu) {
return null;
}
const downloadButton = attachment ? (
<div
onClick={onDownload}
role="button"
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
) : null;
const replyButton = (
<div
onClick={onReply}
role="button"
className={classNames(
'module-message__buttons__reply',
`module-message__buttons__download--${direction}`
)}
/>
);
const menuButton = (
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
<div
role="button"
onClick={this.showMenuBound}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
/>
</ContextMenuTrigger>
);
const first = direction === 'incoming' ? downloadButton : menuButton;
const last = direction === 'incoming' ? menuButton : downloadButton;
return (
<div className="module-message__buttons">
{first}
{replyButton}
{last}
</div>
);
}
public renderContextMenu(triggerId: string) {
const {
direction,
status,
onDelete,
onRetrySend,
onShowDetail,
i18n,
} = this.props;
const showRetry = status === 'error' && direction === 'outgoing';
return (
<ContextMenu id={triggerId}>
<MenuItem onClick={onShowDetail}>{i18n('moreInfo')}</MenuItem>
{showRetry ? (
<MenuItem onClick={onRetrySend}>{i18n('retrySend')}</MenuItem>
) : null}
<MenuItem onClick={onDelete}>{i18n('deleteMessage')}</MenuItem>
</ContextMenu>
);
}
public render() {
const {
attachment,
color,
conversationType,
authorPhoneNumber,
authorColor,
direction,
id,
quote,
text,
timestamp,
} = this.props;
const { expired, expiring } = this.state;
const imageAndNothingElse =
!text && isImage(attachment) && conversationType !== 'group' && !quote;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`);
if (expired) {
return null;
}
return (
<li>
<div
className={classNames(
'module-message',
`module-message--${direction}`,
expiring ? 'module-message--expired' : null
)}
>
{this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)}
<div
id={id}
className={classNames(
'module-message',
`module-message--${direction}`,
imageAndNothingElse ? 'module-message--with-image-only' : null,
'module-message__container',
`module-message__container--${direction}`,
direction === 'incoming'
? `module-message--incoming-${color}`
? `module-message__container--incoming-${authorColor}`
: null
)}
>
@ -583,7 +836,10 @@ export class Message extends React.Component<Props> {
{this.renderSendMessageButton()}
{this.renderAvatar()}
</div>
</li>
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}
</div>
);
}
}

View File

@ -0,0 +1,128 @@
### Incoming message
```jsx
<MessageDetail
message={{
disableMenu: true,
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'grey',
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
onDelete: () => console.log('onDelete'),
}}
sentAt={Date.now() - 2 * 60 * 1000}
receivedAt={Date.now() - 10 * 1000}
contacts={[
{
phoneNumber: '(202) 555-1001',
avatarPath: util.gifObjectUrl,
},
]}
i18n={util.i18n}
/>
```
### Message to group, multiple contacts
```jsx
<MessageDetail
message={{
disableMenu: true,
direction: 'outgoing',
timestamp: Date.now(),
authorColor: 'grey',
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
status: 'read',
onDelete: () => console.log('onDelete'),
}}
sentAt={Date.now()}
contacts={[
{
phoneNumber: '(202) 555-1001',
profileName: 'Mr. Fire',
avatarPath: util.gifObjectUrl,
status: 'sending',
},
{
phoneNumber: '(202) 555-1002',
avatarPath: util.pngObjectUrl,
status: 'delivered',
},
{
phoneNumber: '(202) 555-1003',
color: 'teal',
status: 'read',
},
]}
i18n={util.i18n}
/>
```
### 1:1 conversation, just one recipient
```jsx
<MessageDetail
message={{
disableMenu: true,
direction: 'outgoing',
timestamp: Date.now(),
authorColor: 'grey',
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
status: 'sending',
onDelete: () => console.log('onDelete'),
}}
contacts={[
{
phoneNumber: '(202) 555-1001',
avatarPath: util.gifObjectUrl,
status: 'sending',
},
]}
sentAt={Date.now()}
i18n={util.i18n}
/>
```
### Errors for some users, including on OutgoingKeyError
```jsx
<MessageDetail
message={{
disableMenu: true,
direction: 'outgoing',
timestamp: Date.now(),
authorColor: 'grey',
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
status: 'error',
onDelete: () => console.log('onDelete'),
}}
contacts={[
{
phoneNumber: '(202) 555-1001',
avatarPath: util.gifObjectUrl,
status: 'error',
errors: [new Error('Something went wrong'), new Error('Bad things')],
},
{
phoneNumber: '(202) 555-1002',
avatarPath: util.pngObjectUrl,
status: 'error',
isOutgoingKeyError: true,
errors: [new Error(util.i18n('newIdentity'))],
onShowSafetyNumber: () => console.log('onShowSafetyNumber'),
onSendAnyway: () => console.log('onSendAnyway'),
},
{
phoneNumber: '(202) 555-1003',
color: 'teal',
status: 'read',
},
]}
sentAt={Date.now()}
i18n={util.i18n}
/>
```

View File

@ -0,0 +1,209 @@
import React from 'react';
import classNames from 'classnames';
import moment from 'moment';
import { ContactName } from './ContactName';
import { Message, Props as MessageProps } from './Message';
import { Localizer } from '../../types/Util';
interface Contact {
status: string;
phoneNumber: string;
name?: string;
profileName?: string;
avatarPath?: string;
color: string;
isOutgoingKeyError: boolean;
errors?: Array<Error>;
onSendAnyway: () => void;
onShowSafetyNumber: () => void;
}
interface Props {
sentAt: number;
receivedAt: number;
message: MessageProps;
errors: Array<Error>;
contacts: Array<Contact>;
i18n: Localizer;
}
function getInitial(name: string): string {
return name.trim()[0] || '#';
}
export class MessageDetail extends React.Component<Props> {
public renderAvatar(contact: Contact) {
const { i18n } = this.props;
const { avatarPath, color, phoneNumber, name, profileName } = contact;
if (!avatarPath) {
const initial = getInitial(name || '');
return (
<div
className={classNames(
'module-message-detail__contact__avatar',
'module-message-detail__contact__default-avatar',
`module-message-detail__contact__default-avatar--${color}`
)}
>
{initial}
</div>
);
}
const title = `${name || phoneNumber}${
!name && profileName ? ` ~${profileName}` : ''
}`;
return (
<img
className="module-message-detail__contact__avatar"
alt={i18n('contactAvatarAlt', [title])}
src={avatarPath}
/>
);
}
public renderDeleteButton() {
const { i18n, message } = this.props;
return (
<div className="module-message-detail__delete-button-container">
<button
onClick={message.onDelete}
className="module-message-detail__delete-button"
>
{i18n('deleteThisMessage')}
</button>
</div>
);
}
public renderContact(contact: Contact) {
const { i18n } = this.props;
const errors = contact.errors || [];
const errorComponent = contact.isOutgoingKeyError ? (
<div className="module-message-detail__contact__error-buttons">
<button
className="module-message-detail__contact__show-safety-number"
onClick={contact.onShowSafetyNumber}
>
{i18n('showSafetyNumber')}
</button>
<button
className="module-message-detail__contact__send-anyway"
onClick={contact.onSendAnyway}
>
{i18n('sendAnyway')}
</button>
</div>
) : null;
const statusComponent = !contact.isOutgoingKeyError ? (
<div
className={classNames(
'module-message-detail__contact__status-icon',
`module-message-detail__contact__status-icon--${contact.status}`
)}
/>
) : null;
return (
<div key={contact.phoneNumber} className="module-message-detail__contact">
{this.renderAvatar(contact)}
<div className="module-message-detail__contact__text">
<div className="module-message-detail__contact__name">
<ContactName
phoneNumber={contact.phoneNumber}
name={contact.name}
profileName={contact.profileName}
i18n={i18n}
/>
</div>
{errors.map((error, index) => (
<div key={index} className="module-message-detail__contact__error">
{error.message}
</div>
))}
</div>
{errorComponent}
{statusComponent}
</div>
);
}
public renderContacts() {
const { contacts } = this.props;
if (!contacts || !contacts.length) {
return null;
}
return (
<div className="module-message-detail__contact-container">
{contacts.map(contact => this.renderContact(contact))}
</div>
);
}
public render() {
const { errors, message, receivedAt, sentAt, i18n } = this.props;
return (
<div className="module-message-detail">
<div className="module-message-detail__message-container">
<Message i18n={i18n} {...message} />
</div>
<table className="module-message-detail__info">
<tbody>
{(errors || []).map(error => (
<tr>
<td className="module-message-detail__label">
{i18n('error')}
</td>
<td>
{' '}
<span className="error-message">{error.message}</span>{' '}
</td>
</tr>
))}
<tr>
<td className="module-message-detail__label">{i18n('sent')}</td>
<td>
{moment(sentAt).format('LLLL')}{' '}
<span className="module-message-detail__unix-timestamp">
({sentAt})
</span>
</td>
</tr>
{receivedAt ? (
<tr>
<td className="module-message-detail__label">
{i18n('received')}
</td>
<td>
{moment(receivedAt).format('LLLL')}{' '}
<span className="module-message-detail__unix-timestamp">
({receivedAt})
</span>
</td>
</tr>
) : null}
<tr>
<td className="module-message-detail__label">
{message.direction === 'incoming' ? i18n('from') : i18n('to')}
</td>
</tr>
</tbody>
</table>
{this.renderContacts()}
{this.renderDeleteButton()}
</div>
);
}
}

View File

@ -1,151 +0,0 @@
### Timer change
```jsx
const fromOther = new Whisper.Message({
type: 'incoming',
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
source: '+12025550003',
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
expirationTimerUpdate: {
source: '+12025550003',
},
});
const fromUpdate = new Whisper.Message({
type: 'incoming',
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
source: util.ourNumber,
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
expirationTimerUpdate: {
fromSync: true,
source: util.ourNumber,
},
});
const fromMe = new Whisper.Message({
type: 'incoming',
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
source: util.ourNumber,
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
expirationTimerUpdate: {
source: util.ourNumber,
},
});
const View = Whisper.ExpirationTimerUpdateView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: fromOther }} />
<util.BackboneWrapper View={View} options={{ model: fromUpdate }} />
<util.BackboneWrapper View={View} options={{ model: fromMe }} />
<Notification type="timerUpdate" onClick={() => console.log('onClick')} />
</util.ConversationContext>;
```
### Safety number change
```js
const incoming = new Whisper.Message({
type: 'keychange',
sent_at: Date.now() - 200000,
key_changed: '+12025550003',
});
const View = Whisper.KeyChangeView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
</util.ConversationContext>;
```
### Marking as verified
```js
const fromPrimary = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
verified: true,
});
const local = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
local: true,
verified: true,
});
const View = Whisper.VerifiedChangeView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
<util.BackboneWrapper View={View} options={{ model: local }} />
</util.ConversationContext>;
```
### Marking as not verified
```js
const fromPrimary = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
});
const local = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
local: true,
});
const View = Whisper.VerifiedChangeView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
<util.BackboneWrapper View={View} options={{ model: local }} />
</util.ConversationContext>;
```
### Group update
```js
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
group_update: {
joined: ['+12025550007', '+12025550008', '+12025550009'],
},
});
const incoming = new Whisper.Message(
Object.assign({}, outgoing.attributes, {
source: '+12025550003',
type: 'incoming',
})
);
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
### End session
```js
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
});
const incoming = new Whisper.Message(
Object.assign({}, outgoing.attributes, {
source: '+12025550003',
type: 'incoming',
})
);
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```

View File

@ -1,32 +0,0 @@
import React from 'react';
import classNames from 'classnames';
interface Props {
type: string;
onClick: () => void;
}
export class Notification extends React.Component<Props> {
public renderContents() {
const { type } = this.props;
return <span>Notification of type {type}</span>;
}
public render() {
const { onClick } = this.props;
return (
<div
role="button"
onClick={onClick}
className={classNames(
'module-notification',
onClick ? 'module-notification--with-click-handler' : null
)}
>
{this.renderContents()}
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,15 +6,16 @@ import classNames from 'classnames';
import * as MIME from '../../../ts/types/MIME';
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
import { Emojify } from './Emojify';
import { MessageBody } from './MessageBody';
import { Localizer } from '../../types/Util';
import { Color, Localizer } from '../../types/Util';
import { ContactName } from './ContactName';
interface Props {
attachments: Array<QuotedAttachment>;
color: string;
attachment?: QuotedAttachment;
authorPhoneNumber: string;
authorProfileName?: string;
authorTitle: string;
authorName?: string;
authorColor: Color;
i18n: Localizer;
isFromMe: boolean;
isIncoming: boolean;
@ -43,7 +44,7 @@ function validateQuote(quote: Props): boolean {
return true;
}
if (quote.attachments && quote.attachments.length > 0) {
if (quote.attachment) {
return true;
}
@ -124,14 +125,13 @@ export class Quote extends React.Component<Props> {
}
public renderGenericFile() {
const { attachments } = this.props;
const { attachment } = this.props;
if (!attachments || !attachments.length) {
if (!attachment) {
return;
}
const first = attachments[0];
const { fileName, contentType } = first;
const { fileName, contentType } = attachment;
const isGenericFile =
!GoogleChrome.isVideoTypeSupported(contentType) &&
!GoogleChrome.isImageTypeSupported(contentType) &&
@ -150,13 +150,12 @@ export class Quote extends React.Component<Props> {
}
public renderIconContainer() {
const { attachments, i18n } = this.props;
if (!attachments || attachments.length === 0) {
const { attachment, i18n } = this.props;
if (!attachment) {
return null;
}
const first = attachments[0];
const { contentType, thumbnail } = first;
const { contentType, thumbnail } = attachment;
const objectUrl = getObjectUrl(thumbnail);
if (GoogleChrome.isVideoTypeSupported(contentType)) {
@ -177,7 +176,7 @@ export class Quote extends React.Component<Props> {
}
public renderText() {
const { i18n, text, attachments } = this.props;
const { i18n, text, attachment } = this.props;
if (text) {
return (
@ -187,12 +186,11 @@ export class Quote extends React.Component<Props> {
);
}
if (!attachments || attachments.length === 0) {
if (!attachment) {
return null;
}
const first = attachments[0];
const { contentType, isVoiceMessage } = first;
const { contentType, isVoiceMessage } = attachment;
const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage });
if (typeLabel) {
@ -231,29 +229,44 @@ export class Quote extends React.Component<Props> {
}
public renderAuthor() {
const { authorProfileName, authorTitle, i18n, isFromMe } = this.props;
const authorProfileElement = authorProfileName ? (
<span className="module-quote__primary__profile-name">
~<Emojify text={authorProfileName} i18n={i18n} />
</span>
) : null;
const {
authorProfileName,
authorPhoneNumber,
authorName,
authorColor,
i18n,
isFromMe,
} = this.props;
return (
<div className="module-quote__primary__author">
<div
className={classNames(
'module-quote__primary__author',
!isFromMe ? `module-quote__primary__author--${authorColor}` : null
)}
>
{isFromMe ? (
i18n('you')
) : (
<span>
<Emojify text={authorTitle} i18n={i18n} /> {authorProfileElement}
</span>
<ContactName
phoneNumber={authorPhoneNumber}
name={authorName}
profileName={authorProfileName}
i18n={i18n}
/>
)}
</div>
);
}
public render() {
const { color, isIncoming, onClick, withContentAbove } = this.props;
const {
authorColor,
isFromMe,
isIncoming,
onClick,
withContentAbove,
} = this.props;
if (!validateQuote(this.props)) {
return null;
@ -266,7 +279,10 @@ export class Quote extends React.Component<Props> {
className={classNames(
'module-quote',
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
!isIncoming ? `module-quote--outgoing-${color}` : null,
!isIncoming && !isFromMe
? `module-quote--outgoing-${authorColor}`
: null,
!isIncoming && isFromMe ? 'module-quote--outgoing-you' : null,
!onClick ? 'module-quote--no-click' : null,
withContentAbove ? 'module-quote--with-content-above' : null
)}

View File

@ -0,0 +1,7 @@
### End session
```js
<util.ConversationContext theme={util.theme}>
<ResetSessionNotification i18n={util.i18n} />
</util.ConversationContext>
```

View File

@ -0,0 +1,19 @@
import React from 'react';
import { Localizer } from '../../types/Util';
interface Props {
i18n: Localizer;
}
export class ResetSessionNotification extends React.Component<Props> {
public render() {
const { i18n } = this.props;
return (
<div className="module-reset-session-notification">
{i18n('sessionEnded')}
</div>
);
}
}

View File

@ -0,0 +1,25 @@
### In group conversation
```js
<util.ConversationContext theme={util.theme}>
<SafetyNumberNotification
i18n={util.i18n}
isGroup={true}
contact={{ phoneNumber: '(202) 500-1000', profileName: 'Mr. Fire' }}
onVerify={() => console.log('onVerify')}
/>
</util.ConversationContext>
```
### In one-on-one conversation
```js
<util.ConversationContext theme={util.theme}>
<SafetyNumberNotification
i18n={util.i18n}
isGroup={false}
contact={{ phoneNumber: '(202) 500-1000', profileName: 'Mr. Fire' }}
onVerify={() => console.log('onVerify')}
/>
</util.ConversationContext>
```

View File

@ -0,0 +1,58 @@
import React from 'react';
// import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { Localizer } from '../../types/Util';
interface Contact {
phoneNumber: string;
profileName?: string;
name?: string;
}
interface Props {
isGroup: boolean;
contact: Contact;
i18n: Localizer;
onVerify: () => void;
}
export class SafetyNumberNotification extends React.Component<Props> {
public render() {
const { contact, isGroup, i18n, onVerify } = this.props;
return (
<div className="module-safety-number-notification">
<div className="module-safety-number-notification__icon" />
<div className="module-safety-number-notification__text">
<Intl
id={isGroup ? 'safetyNumberChangedGroup' : 'safetyNumberChanged'}
components={[
<span
key="external-1"
className="module-safety-number-notification__contact"
>
<ContactName
i18n={i18n}
name={contact.name}
profileName={contact.profileName}
phoneNumber={contact.phoneNumber}
module="module-verification-notification__contact"
/>
</span>,
]}
i18n={i18n}
/>
</div>
<div
role="button"
onClick={onVerify}
className="module-verification-notification__button"
>
{i18n('verifyNewNumber')}
</div>
</div>
);
}
}

View File

@ -0,0 +1,39 @@
### From other
```jsx
<util.ConversationContext theme={util.theme}>
<TimerNotification
type="fromOther"
phoneNumber="(202) 555-1000"
profileName="Mr. Fire"
timespan="1 hour"
i18n={util.i18n}
/>
</util.ConversationContext>
```
### You changed
```jsx
<util.ConversationContext theme={util.theme}>
<TimerNotification
type="fromMe"
phoneNumber="(202) 555-1000"
timespan="1 hour"
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Changed via sync
```jsx
<util.ConversationContext theme={util.theme}>
<TimerNotification
type="fromSync"
phoneNumber="(202) 555-1000"
timespan="1 hour"
i18n={util.i18n}
/>
</util.ConversationContext>
```

View File

@ -0,0 +1,67 @@
import React from 'react';
// import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { Localizer } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
interface Props {
type: 'fromOther' | 'fromMe' | 'fromSync';
phoneNumber: string;
profileName?: string;
name?: string;
timespan: string;
i18n: Localizer;
}
export class TimerNotification extends React.Component<Props> {
public renderContents() {
const { i18n, name, phoneNumber, profileName, timespan, type } = this.props;
switch (type) {
case 'fromOther':
return (
<Intl
i18n={i18n}
id="theyChangedTheTimer"
components={[
<ContactName
i18n={i18n}
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
name={name}
/>,
timespan,
]}
/>
);
case 'fromMe':
return i18n('youChangedTheTimer', [timespan]);
case 'fromSync':
return i18n('timerSetOnSync', [timespan]);
default:
throw missingCaseError(type);
}
}
public render() {
const { timespan } = this.props;
return (
<div className="module-timer-notification">
<div className="module-timer-notification__icon-container">
<div className="module-timer-notification__icon" />
<div className="module-timer-notification__icon-label">
{timespan}
</div>
</div>
<div className="module-timer-notification__message">
{this.renderContents()}
</div>
</div>
);
}
}

View File

@ -0,0 +1,167 @@
### All major transitions
```jsx
function get1201() {
const d = new Date();
d.setHours(0, 0, 1, 0);
return d.getTime();
}
function getYesterday1159() {
return get1201() - 2 * 60 * 1000;
}
function getJanuary1201() {
const now = new Date();
const d = new Date(now.getFullYear(), 0, 1, 0, 1);
return d.getTime();
}
function getDecember1159() {
return getJanuary1201() - 2 * 60 * 1000;
}
<util.ConversationContext theme={util.theme}>
<li>
<Message
direction="incoming"
authorColor="red"
timestamp={Date.now() - 500}
text="500ms ago - all below 1 minute are 'now'"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 5 * 1000}
text="Five seconds ago"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 30 * 1000}
text="30 seconds ago"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
timestamp={Date.now() - 60 * 1000}
text="One minute ago - in minutes"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 30 * 60 * 1000}
text="30 minutes ago"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 45 * 60 * 1000}
text="45 minutes ago (used to round up to 1 hour with moment)"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
timestamp={Date.now() - 60 * 60 * 1000}
text="One hour ago - in hours"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={get1201()}
text="12:01am today"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
timestamp={getYesterday1159()}
text="11:59pm yesterday - adds day name"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 24 * 60 * 60 * 1000}
text="24 hours ago"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 2 * 24 * 60 * 60 * 1000}
text="Two days ago"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
timestamp={Date.now() - 7 * 24 * 60 * 60 * 1000}
text="Seven days ago - adds month"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 30 * 24 * 60 * 60 * 1000}
text="Thirty days ago"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={getJanuary1201()}
text="January 1st at 12:01am"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
timestamp={getDecember1159()}
text="December 31st at 11:59pm - adds year"
i18n={util.i18n}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="teal"
timestamp={Date.now() - 366 * 24 * 60 * 60 * 1000}
text="One year ago"
i18n={util.i18n}
/>
</li>
</util.ConversationContext>;
```

View File

@ -0,0 +1,66 @@
import React from 'react';
import classNames from 'classnames';
import moment from 'moment';
import { formatRelativeTime } from '../../util/formatRelativeTime';
import { Localizer } from '../../types/Util';
interface Props {
timestamp: number;
withImageNoCaption: boolean;
direction: 'incoming' | 'outgoing';
module?: string;
i18n: Localizer;
}
const UPDATE_FREQUENCY = 60 * 1000;
export class Timestamp extends React.Component<Props> {
private interval: any;
constructor(props: Props) {
super(props);
this.interval = null;
}
public componentDidMount() {
const update = () => {
this.setState({
lastUpdated: Date.now(),
});
};
this.interval = setInterval(update, UPDATE_FREQUENCY);
}
public componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
public render() {
const {
direction,
i18n,
module,
timestamp,
withImageNoCaption,
} = this.props;
const moduleName = module || 'module-timestamp';
return (
<span
className={classNames(
moduleName,
`${moduleName}--${direction}`,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null
)}
title={moment(timestamp).format('llll')}
>
{formatRelativeTime(timestamp, { i18n, extended: true })}
</span>
);
}
}

View File

@ -0,0 +1,49 @@
### Marking as verified
```js
<util.ConversationContext theme={util.theme}>
<VerificationNotification
type="markVerified"
isLocal={true}
contact={{
phoneNumber: '(202) 555-0003',
profileName: 'Mr. Fire',
}}
i18n={util.i18n}
/>
<VerificationNotification
type="markVerified"
isLocal={false}
contact={{
phoneNumber: '(202) 555-0003',
profileName: 'Mr. Fire',
}}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Marking as not verified
```js
<util.ConversationContext theme={util.theme}>
<VerificationNotification
type="markNotVerified"
isLocal={true}
contact={{
phoneNumber: '(202) 555-0003',
profileName: 'Mr. Fire',
}}
i18n={util.i18n}
/>
<VerificationNotification
type="markNotVerified"
isLocal={false}
contact={{
phoneNumber: '(202) 555-0003',
profileName: 'Mr. Fire',
}}
i18n={util.i18n}
/>
</util.ConversationContext>
```

View File

@ -0,0 +1,75 @@
import React from 'react';
// import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { Localizer } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
interface Contact {
phoneNumber: string;
profileName?: string;
name?: string;
}
interface Props {
type: 'markVerified' | 'markNotVerified';
isLocal: boolean;
contact: Contact;
i18n: Localizer;
}
export class VerificationNotification extends React.Component<Props> {
public getStringId() {
const { isLocal, type } = this.props;
switch (type) {
case 'markVerified':
return isLocal
? 'youMarkedAsVerified'
: 'youMarkedAsVerifiedOtherDevice';
case 'markNotVerified':
return isLocal
? 'youMarkedAsNotVerified'
: 'youMarkedAsNotVerifiedOtherDevice';
default:
throw missingCaseError(type);
}
}
public renderContents() {
const { contact, i18n } = this.props;
const id = this.getStringId();
return (
<Intl
id={id}
components={[
<ContactName
i18n={i18n}
key="external-1"
name={contact.name}
profileName={contact.profileName}
phoneNumber={contact.phoneNumber}
module="module-verification-notification__contact"
/>,
]}
i18n={i18n}
/>
);
}
public render() {
const { type } = this.props;
const suffix =
type === 'markVerified' ? 'mark-verified' : 'mark-not-verified';
return (
<div className="module-verification-notification">
<div className={`module-verification-notification__icon--${suffix}`} />
{this.renderContents()}
</div>
);
}
}

View File

@ -1,6 +1,7 @@
```jsx
const messages = [
{
id: '1',
attachments: [
{
fileName: 'foo.json',
@ -10,6 +11,7 @@ const messages = [
],
},
{
id: '2',
attachments: [
{
fileName: 'bar.txt',

View File

@ -6,10 +6,10 @@
display: 'flex',
position: 'relative',
width: '100%',
height: 300,
height: 200,
}}
>
<EmptyState label="You have no attachments with media" />
<EmptyState label={util.i18n('mediaEmptyState')} />
</div>
```
@ -24,6 +24,6 @@
height: 500,
}}
>
<EmptyState label="You have no documents with media" />
<EmptyState label={util.i18n('documentsEmptyState')} />
</div>
```

View File

@ -14,6 +14,7 @@
### Media gallery with media and documents
```jsx
const _ = util._;
const DAY_MS = 24 * 60 * 60 * 1000;
const MONTH_MS = 30 * DAY_MS;
const YEAR_MS = 12 * MONTH_MS;
@ -81,7 +82,14 @@ const messages = _.sortBy(
```jsx
const messages = [
{
attachments: [{ fileName: 'foo.jpg', contentType: 'application/json' }],
id: '1',
objectURL: 'https://placekitten.com/76/67',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'application/json',
},
],
},
];
<MediaGallery i18n={util.i18n} media={messages} documents={messages} />;

View File

@ -0,0 +1,30 @@
## With image
```jsx
const message = {
id: '1',
objectURL: 'https://placekitten.com/76/67',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'application/json',
},
],
};
<MediaGridItem i18n={util.i18n} message={message} />;
```
## Without image
```jsx
const message = {
id: '1',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'application/json',
},
],
};
<MediaGridItem i18n={util.i18n} message={message} />;
```

View File

@ -1,17 +0,0 @@
Rendering a real `Whisper.MessageView` using `<util.ConversationContext />` and
`<util.BackboneWrapper />`.
```jsx
const model = new Whisper.Message({
type: 'outgoing',
body: 'text',
sent_at: Date.now() - 5000,
});
const View = Whisper.MessageView;
const options = {
model,
};
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={options} />
</util.ConversationContext>;
```

View File

@ -1,68 +0,0 @@
import React from 'react';
interface Props {
/** The View class, which will be instantiated then treated like a Backbone View */
readonly View: BackboneViewConstructor;
/** Options to be passed along to the view when constructed */
readonly options: object;
}
interface BackboneView {
remove: () => void;
render: () => void;
el: HTMLElement;
}
interface BackboneViewConstructor {
new (options: object): BackboneView;
}
/**
* Allows Backbone Views to be rendered inside of React (primarily for the Style Guide)
* while we slowly replace the internals of a given Backbone view with React.
*/
export class BackboneWrapper extends React.Component<Props> {
protected el: HTMLElement | null = null;
protected view: BackboneView | null = null;
public componentWillUnmount() {
this.teardown();
}
public shouldComponentUpdate() {
// we're handling all updates manually
return false;
}
public render() {
return <div ref={this.setEl} />;
}
protected setEl = (element: HTMLDivElement | null) => {
this.el = element;
this.setup();
};
protected setup = () => {
const { View, options } = this.props;
if (!this.el) {
return;
}
this.view = new View(options);
this.view.render();
// It's important to let the view create its own root DOM element. This ensures that
// its tagName property actually takes effect.
this.el.appendChild(this.view.el);
};
protected teardown() {
if (!this.view) {
return;
}
this.view.remove();
this.view = null;
}
}

127
ts/selectors/message.ts Normal file
View File

@ -0,0 +1,127 @@
export function messageSelector({ model, view }: { model: any; view: any }) {
// tslint:disable-next-line
console.log({ model, view });
return null;
// const avatar = this.model.getAvatar();
// const avatarPath = avatar && avatar.url;
// const color = avatar && avatar.color;
// const isMe = this.ourNumber === this.model.id;
// const attachments = this.model.get('attachments') || [];
// const loadedAttachmentViews = Promise.all(
// attachments.map(
// attachment =>
// new Promise(async resolve => {
// const attachmentWithData = await loadAttachmentData(attachment);
// const view = new Whisper.AttachmentView({
// model: attachmentWithData,
// timestamp: this.model.get('sent_at'),
// });
// this.listenTo(view, 'update', () => {
// // NOTE: Can we do without `updated` flag now that we use promises?
// view.updated = true;
// resolve(view);
// });
// view.render();
// })
// )
// );
// Wiring up TimerNotification
// this.conversation = this.model.getExpirationTimerUpdateSource();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// this.listenTo(this.model, 'change', this.onChange);
// Wiring up SafetyNumberNotification
// this.conversation = this.model.getModelForKeyChange();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// Wiring up VerificationNotification
// this.conversation = this.model.getModelForVerifiedChange();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// this.contactView = new Whisper.ReactWrapperView({
// className: 'contact-wrapper',
// Component: window.Signal.Components.ContactListItem,
// props: {
// isMe,
// color,
// avatarPath,
// phoneNumber: model.getNumber(),
// name: model.getName(),
// profileName: model.getProfileName(),
// verified: model.isVerified(),
// onClick: showIdentity,
// },
// });
// this.$el.append(this.contactView.el);
}
// We actually don't listen to the model telling us that it's gone if it's disappearing
// onDestroy() {
// if (this.$el.hasClass('expired')) {
// return;
// }
// this.onUnload();
// },
// The backflips required to maintain scroll position when loading images
// Key is only adding the img to the DOM when the image has loaded.
//
// How might we get similar behavior with React?
//
// this.trigger('beforeChangeHeight');
// this.$('.attachments').append(view.el);
// view.setElement(view.el);
// this.trigger('afterChangeHeight');
// Timer code
// if (this.model.isExpired()) {
// return this;
// }
// if (this.model.isExpiring()) {
// this.render();
// const totalTime = this.model.get('expireTimer') * 1000;
// const remainingTime = this.model.msTilExpire();
// const elapsed = (totalTime - remainingTime) / totalTime;
// this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
// this.$el.css('display', 'inline-block');
// this.timeout = setTimeout(
// this.update.bind(this),
// Math.max(totalTime / 100, 500)
// );
// }
// Expiring message
// this.$el.addClass('expired');
// this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
// if (e.target === this.$('.bubble')[0]) {
// this.remove();
// }
// });
// // Failsafe: if in the background, animation events don't fire
// setTimeout(this.remove.bind(this), 1000);
// Retrying a message
// retryMessage() {
// const retrys = _.filter(
// this.model.get('errors'),
// this.model.isReplayableError.bind(this.model)
// );
// _.map(retrys, 'number').forEach(number => {
// this.model.resend(number);
// });
// },

View File

@ -1,22 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { default as _, padStart, sample } from 'lodash';
import libphonenumber from 'google-libphonenumber';
import moment from 'moment';
import QueryString from 'qs';
export { _ };
// Helper components used in the Style Guide, exposed at 'util' in the global scope via
// the 'context' option in react-styleguidist.
// This file provides helpers for the Style Guide, exposed at 'util' in the global scope
// via the 'context' option in react-styleguidist.
import { default as _ } from 'lodash';
export { ConversationContext } from './ConversationContext';
export { BackboneWrapper } from '../components/utility/BackboneWrapper';
// @ts-ignore
import * as Signal from '../../js/modules/signal';
import { SignalService } from '../protobuf';
export { _ };
// TypeScript wants two things when you import:
// 1) a normal typescript file
@ -25,6 +15,7 @@ import { SignalService } from '../protobuf';
// @ts-ignore
import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif';
// 320x240
const gifObjectUrl = makeObjectUrl(gif, 'image/gif');
// @ts-ignore
import mp3 from '../../fixtures/incompetech-com-Agnus-Dei-X.mp3';
@ -37,6 +28,7 @@ import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4');
// @ts-ignore
import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png';
// 800×1200
const pngObjectUrl = makeObjectUrl(png, 'image/png');
// @ts-ignore
@ -63,9 +55,6 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
return URL.createObjectURL(blob);
}
const ourNumber = '+12025559999';
const groupNumber = '+12025550099';
export {
mp3,
mp3ObjectUrl,
@ -87,13 +76,8 @@ export {
landscapeRedObjectUrl,
portraitTeal,
portraitTealObjectUrl,
ourNumber,
groupNumber,
};
// Required, or TypeScript complains about adding keys to window
const parent = window as any;
const query = window.location.search.replace(/^\?/, '');
const urlOptions = QueryString.parse(query);
const theme = urlOptions.theme || 'light-theme';
@ -104,123 +88,10 @@ import localeMessages from '../../_locales/en/messages.json';
// @ts-ignore
import { setup } from '../../js/modules/i18n';
import fileSize from 'filesize';
const i18n = setup(locale, localeMessages);
parent.filesize = fileSize;
parent.i18n = i18n;
parent.React = React;
parent.ReactDOM = ReactDOM;
parent.moment = moment;
parent.moment.updateLocale(locale, {
relativeTime: {
h: parent.i18n('timestamp_h'),
m: parent.i18n('timestamp_m'),
s: parent.i18n('timestamp_s'),
},
});
parent.moment.locale(locale);
export { theme, locale, i18n };
// Used by signal.js to set up code that deals with message attachments/avatars
const Attachments = {
createAbsolutePathGetter: () => () => '/fake/path',
createDeleter: () => async () => undefined,
createReader: () => async () => new ArrayBuffer(10),
createWriterForExisting: () => async () => '/fake/path',
createWriterForNew: () => async () => ({
data: new ArrayBuffer(10),
path: '/fake/path',
}),
getPath: (path: string) => path,
};
parent.Signal = Signal.setup({
Attachments,
userDataPath: '/',
// tslint:disable-next-line:no-backbone-get-set-outside-model
getRegionCode: () => parent.storage.get('regionCode'),
});
parent.SignalService = SignalService;
parent.ConversationController._initialFetchComplete = true;
parent.ConversationController._initialPromise = Promise.resolve();
const COLORS = [
'red',
'pink',
'purple',
'deep_purple',
'indigo',
'blue',
'light_blue',
'cyan',
'teal',
'green',
'light_green',
'orange',
'deep_orange',
'amber',
'blue_grey',
'grey',
'default',
];
const CONTACTS = COLORS.map((color, index) => {
const title = `${sample(['Mr.', 'Mrs.', 'Ms.', 'Unknown'])} ${color} 🔥`;
const key = sample(['name', 'profileName']) as string;
const id = `+1202555${padStart(index.toString(), 4, '0')}`;
const contact = {
color,
[key]: title,
id,
type: 'private',
};
return parent.ConversationController.dangerouslyCreateAndAdd(contact);
});
const me = parent.ConversationController.dangerouslyCreateAndAdd({
id: ourNumber,
name: 'Me!',
type: 'private',
color: 'light_blue',
});
const group = parent.ConversationController.dangerouslyCreateAndAdd({
id: groupNumber,
name: 'A place for sharing cats',
type: 'group',
});
group.contactCollection.add(me);
group.contactCollection.add(CONTACTS[0]);
group.contactCollection.add(CONTACTS[1]);
group.contactCollection.add(CONTACTS[2]);
export { COLORS, CONTACTS, me, group };
parent.textsecure.storage.user.getNumber = () => ourNumber;
parent.textsecure.messaging = {
getProfile: async (phoneNumber: string): Promise<boolean> => {
if (parent.ConversationController.get(phoneNumber)) {
return true;
}
throw new Error('User does not have Signal account');
},
};
parent.libphonenumber = libphonenumber.PhoneNumberUtil.getInstance();
parent.libphonenumber.PhoneNumberFormat = libphonenumber.PhoneNumberFormat;
parent.storage.put('regionCode', 'US');
// Telling Lodash to relinquish _ for use by underscore
// @ts-ignore
_.noConflict();

View File

@ -70,10 +70,19 @@ export function contactSelector(
contact: Contact,
options: {
regionCode: string;
hasSignalAccount: boolean;
getAbsoluteAttachmentPath: (path: string) => string;
onSendMessage: () => void;
onClick: () => void;
}
) {
const { regionCode, getAbsoluteAttachmentPath } = options;
const {
getAbsoluteAttachmentPath,
hasSignalAccount,
onClick,
onSendMessage,
regionCode,
} = options;
let { avatar } = contact;
if (avatar && avatar.avatar && avatar.avatar.path) {
@ -88,6 +97,9 @@ export function contactSelector(
return {
...contact,
hasSignalAccount,
onSendMessage,
onClick,
avatar,
number:
contact.number &&

View File

@ -6,3 +6,15 @@ export type RenderTextCallback = (
) => JSX.Element | string;
export type Localizer = (key: string, values?: Array<string>) => string;
export type Color =
| 'gray'
| 'blue'
| 'cyan'
| 'deep_orange'
| 'green'
| 'indigo'
| 'pink'
| 'purple'
| 'red'
| 'teal';

View File

@ -1,5 +1,6 @@
import * as GoogleChrome from './GoogleChrome';
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
import { missingCaseError } from './missingCaseError';
import { migrateColor } from './migrateColor';
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError };
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, migrateColor };

Some files were not shown because too many files have changed in this diff Show More