Merge signal-master.

pull/110/head
Mikunj 6 years ago
commit 654b0dac84

@ -1,6 +1,6 @@
# TODO: Figure out a way to use nvm in the linux build
linux:
image: node:8.10.0
image: node:10.13.0
tags:
- docker
script:

@ -1 +1 @@
8.9.3
10.13.0

@ -4,7 +4,10 @@ cache:
directories:
- node_modules
node_js:
- '8.9.3'
- '10.13.0'
os:
- linux
dist: trusty
install:
- yarn install --frozen-lockfile --network-timeout 1000000
script:

@ -400,7 +400,7 @@ module.exports = grunt => {
'test-release',
'Test packaged releases',
function thisNeeded() {
const dir = grunt.option('dir') || 'dist';
const dir = grunt.option('dir') || 'release';
const environment = grunt.option('env') || 'production';
const config = this.data;
const archive = [dir, config.archive].join('/');

@ -653,6 +653,10 @@
"selectAContact": {
"message": "Select a contact or group to start chatting."
},
"typingAlt": {
"message": "Typing animation for this conversation",
"description": "Used as the 'title' attibute for the typing animation"
},
"contactAvatarAlt": {
"message": "Avatar for contact $name$",
"description": "Used in the alt tag for the image avatar of a contact",
@ -927,6 +931,11 @@
"description":
"Used in the alt tag for the image shown in a full-screen lightbox view"
},
"imageCaptionIconAlt": {
"message": "Icon showing that this image has a caption",
"description":
"Used for the icon layered on top of an image in message bubbles"
},
"fileIconAlt": {
"message": "File icon",
"description":

@ -1,16 +1,43 @@
const addUnhandledErrorHandler = require('electron-unhandled');
const electron = require('electron');
const Errors = require('../js/modules/types/errors');
// addHandler :: Unit -> Unit
const { app, dialog, clipboard } = electron;
// We're using hard-coded strings in this file because it needs to be ready
// to report errors before we do anything in the app. Also, we expect users to directly
// paste this text into search engines to find the bugs on GitHub.
function handleError(prefix, error) {
console.error(`${prefix}:`, Errors.toLogFormat(error));
if (app.isReady()) {
// title field is not shown on macOS, so we don't use it
const buttonIndex = dialog.showMessageBox({
buttons: ['OK', 'Copy error'],
defaultId: 0,
detail: error.stack,
message: prefix,
noLink: true,
type: 'error',
});
if (buttonIndex === 1) {
clipboard.writeText(`${prefix}\n${error.stack}`);
}
} else {
dialog.showErrorBox(prefix, error.stack);
}
app.quit();
}
exports.addHandler = () => {
addUnhandledErrorHandler({
logger: error => {
console.error(
'Uncaught error or unhandled promise rejection:',
Errors.toLogFormat(error)
);
},
showDialog: false,
process.on('uncaughtException', error => {
handleError('Unhandled Error', error);
});
process.on('unhandledRejection', error => {
handleError('Unhandled Promise Rejection', error);
});
};

@ -8,7 +8,7 @@ cache:
install:
- systeminfo | findstr /C:"OS"
- set PATH=C:\Ruby23-x64\bin;%PATH%
- ps: Install-Product node 8.9.3 x64
- ps: Install-Product node 10.13.0 x64
- yarn install --frozen-lockfile
build_script:
@ -16,12 +16,11 @@ build_script:
- yarn lint-windows
- yarn lint-deps
- yarn test-node
- yarn nsp check
- node build\grunt.js
- type package.json | findstr /v certificateSubjectName > temp.json
- move temp.json package.json
- yarn prepare-beta-build
- node_modules\.bin\build --config.extraMetadata.environment=%SIGNAL_ENV% --publish=never
- node_modules\.bin\build --config.extraMetadata.environment=%SIGNAL_ENV% --publish=never --config.directories.output=release
test_script:
- node build\grunt.js test

@ -165,6 +165,10 @@
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='message-list'>
<div class='messages'></div>
<div class='typing-container'></div>
</script>
<script type='text/x-tmpl-mustache' id='recorder'>
<button class='finish'><span class='icon'></span></button>
<span class='time'>0:00</span>

Binary file not shown.

@ -0,0 +1,63 @@
<?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 52.3 (67297) - http://www.bohemiancoding.com/sketch -->
<title>caption-shadow-24</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="3" width="18" height="2"></rect>
<filter x="-19.4%" y="-125.0%" width="138.9%" height="450.0%" filterUnits="objectBoundingBox" id="filter-2">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" type="matrix" in="shadowBlurOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
<rect id="path-3" x="0" y="0" width="18" height="2"></rect>
<filter x="-19.4%" y="-125.0%" width="138.9%" height="450.0%" filterUnits="objectBoundingBox" id="filter-4">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" type="matrix" in="shadowBlurOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
<rect id="path-5" x="0" y="6" width="12" height="2"></rect>
<filter x="-29.2%" y="-125.0%" width="158.3%" height="450.0%" filterUnits="objectBoundingBox" id="filter-6">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" type="matrix" in="shadowBlurOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
</defs>
<g id="caption-shadow-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="caption-24" transform="translate(3.000000, 8.000000)">
<g id="Rectangle">
<use fill="black" fill-opacity="1" filter="url(#filter-2)" xlink:href="#path-1"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
</g>
<g id="Rectangle">
<use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-3"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
<g id="Rectangle">
<use fill="black" fill-opacity="1" filter="url(#filter-6)" xlink:href="#path-5"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-5"></use>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@ -753,6 +753,7 @@
messageReceiver.addEventListener('reconnect', onReconnect);
messageReceiver.addEventListener('progress', onProgress);
messageReceiver.addEventListener('configuration', onConfiguration);
messageReceiver.addEventListener('typing', onTyping);
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
@ -786,19 +787,6 @@
// }
const deviceId = textsecure.storage.user.getDeviceId();
const ourNumber = textsecure.storage.user.getNumber();
const { sendRequestConfigurationSyncMessage } = textsecure.messaging;
const status = await Signal.Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId,
sendRequestConfigurationSyncMessage,
storage,
prepareForSend: ConversationController.prepareForSend.bind(
ConversationController
),
});
window.log.info('Sync configuration status:', status);
if (firstRun === true && deviceId !== '1') {
const hasThemeSetting = Boolean(storage.get('theme-setting'));
if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') {
@ -879,10 +867,14 @@
}
function onConfiguration(ev) {
const { configuration } = ev;
const {
readReceipts,
typingIndicators,
unidentifiedDeliveryIndicators,
} = configuration;
storage.put('read-receipt-setting', configuration.readReceipts);
storage.put('read-receipt-setting', readReceipts);
const { unidentifiedDeliveryIndicators } = configuration;
if (
unidentifiedDeliveryIndicators === true ||
unidentifiedDeliveryIndicators === false
@ -892,9 +884,34 @@
unidentifiedDeliveryIndicators
);
}
if (typingIndicators === true || typingIndicators === false) {
storage.put('typingIndicators', typingIndicators);
}
ev.confirm();
}
function onTyping(ev) {
const { typing, sender, senderDevice } = ev;
const { groupId, started } = typing || {};
// We don't do anything with incoming typing messages if the setting is disabled
if (!storage.get('typingIndicators')) {
return;
}
const conversation = ConversationController.get(groupId || sender);
if (conversation) {
conversation.notifyTyping({
isTyping: started,
sender,
senderDevice,
});
}
}
async function onContactReceived(ev) {
const details = ev.contactDetails;

@ -123,6 +123,7 @@
this.messageCollection.on('change:errors', this.handleMessageError, this);
this.messageCollection.on('send-error', this.onMessageError, this);
this.throttledBumpTyping = _.throttle(this.bumpTyping, 300);
const debouncedUpdateLastMessage = _.debounce(
this.updateLastMessage.bind(this),
200
@ -161,6 +162,8 @@
this.unset('lastMessageStatus');
this.setFriendRequestExpiryTimeout();
this.typingRefreshTimer = null;
this.typingPauseTimer = null;
},
isMe() {
@ -179,6 +182,85 @@
this.trigger('change');
this.messageCollection.forEach(m => m.trigger('change'));
},
bumpTyping() {
// We don't send typing messages if the setting is disabled
if (!storage.get('typingIndicators')) {
return;
}
if (!this.typingRefreshTimer) {
const isTyping = true;
this.setTypingRefreshTimer();
this.sendTypingMessage(isTyping);
}
this.setTypingPauseTimer();
},
setTypingRefreshTimer() {
if (this.typingRefreshTimer) {
clearTimeout(this.typingRefreshTimer);
}
this.typingRefreshTimer = setTimeout(
this.onTypingRefreshTimeout.bind(this),
10 * 1000
);
},
onTypingRefreshTimeout() {
const isTyping = true;
this.sendTypingMessage(isTyping);
// This timer will continue to reset itself until the pause timer stops it
this.setTypingRefreshTimer();
},
setTypingPauseTimer() {
if (this.typingPauseTimer) {
clearTimeout(this.typingPauseTimer);
}
this.typingPauseTimer = setTimeout(
this.onTypingPauseTimeout.bind(this),
3 * 1000
);
},
onTypingPauseTimeout() {
const isTyping = false;
this.sendTypingMessage(isTyping);
this.clearTypingTimers();
},
clearTypingTimers() {
if (this.typingPauseTimer) {
clearTimeout(this.typingPauseTimer);
this.typingPauseTimer = null;
}
if (this.typingRefreshTimer) {
clearTimeout(this.typingRefreshTimer);
this.typingRefreshTimer = null;
}
},
sendTypingMessage(isTyping) {
const groupId = !this.isPrivate() ? this.id : null;
const recipientId = this.isPrivate() ? this.id : null;
const sendOptions = this.getSendOptions();
this.wrapSend(
textsecure.messaging.sendTypingMessage(
{
groupId,
isTyping,
recipientId,
},
sendOptions
)
);
},
async cleanup() {
await window.Signal.Types.Conversation.deleteExternalFiles(
this.attributes,
@ -244,6 +326,16 @@
await Promise.all(messages.map(m => m.setCalculatingPoW()));
},
async onNewMessage(message) {
await this.updateLastMessage();
// Clear typing indicator for a given contact if we receive a message from them
const identifier = message.get
? `${message.get('source')}.${message.get('sourceDevice')}`
: `${message.source}.${message.sourceDevice}`;
this.clearContactTypingTimer(identifier);
},
addSingleMessage(message, setToExpire = true) {
const model = this.messageCollection.add(message, { merge: true });
if (setToExpire) model.setToExpire();
@ -285,6 +377,8 @@
});
},
getPropsForListItem() {
const typingKeys = Object.keys(this.contactTypingTimers || {});
const result = {
...this.format(),
conversationType: this.isPrivate() ? 'direct' : 'group',
@ -294,6 +388,8 @@
isSelected: this.isSelected,
showFriendRequestIndicator: this.isPendingFriendRequest(),
isBlocked: this.isBlocked(),
isTyping: typingKeys.length > 0,
lastMessage: {
status: this.lastMessageStatus,
text: this.lastMessage,
@ -972,6 +1068,9 @@
// Input should be blocked if there is a pending friend request
if (this.isPendingFriendRequest())
return;
this.clearTypingTimers();
const destination = this.id;
const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients();
@ -1221,6 +1320,10 @@
getNumberInfo(options = {}) {
const { syncMessage, disableMeCheck } = options;
if (!this.ourNumber) {
return null;
}
// START: this code has an Expiration date of ~2018/11/21
// We don't want to enable unidentified delivery for send unless it is
// also enabled for our own account.
@ -1276,10 +1379,6 @@
},
};
},
// eslint-disable-next-line no-unused-vars
async onNewMessage(message) {
return this.updateLastMessage();
},
async updateLastMessage() {
if (!this.id) {
return;
@ -2068,6 +2167,69 @@
title: i18n(title),
});
},
notifyTyping(options = {}) {
const { isTyping, sender, senderDevice } = options;
// We don't do anything with typing messages from our other devices
if (sender === this.ourNumber) {
return;
}
const identifier = `${sender}.${senderDevice}`;
this.contactTypingTimers = this.contactTypingTimers || {};
const record = this.contactTypingTimers[identifier];
if (record) {
clearTimeout(record.timer);
}
// Note: We trigger two events because:
// 'typing-update' is a surgical update ConversationView does for in-convo bubble
// 'change' causes a re-render of this conversation's list item in the left pane
if (isTyping) {
this.contactTypingTimers[identifier] = this.contactTypingTimers[
identifier
] || {
timestamp: Date.now(),
sender,
senderDevice,
};
this.contactTypingTimers[identifier].timer = setTimeout(
this.clearContactTypingTimer.bind(this, identifier),
15 * 1000
);
if (!record) {
// User was not previously typing before. State change!
this.trigger('typing-update');
this.trigger('change');
}
} else {
delete this.contactTypingTimers[identifier];
if (record) {
// User was previously typing, and is no longer. State change!
this.trigger('typing-update');
this.trigger('change');
}
}
},
clearContactTypingTimer(identifier) {
this.contactTypingTimers = this.contactTypingTimers || {};
const record = this.contactTypingTimers[identifier];
if (record) {
clearTimeout(record.timer);
delete this.contactTypingTimers[identifier];
// User was previously typing, but timed out or we received message. State change!
this.trigger('typing-update');
this.trigger('change');
}
},
});
Whisper.ConversationCollection = Backbone.Collection.extend({

@ -491,8 +491,8 @@
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const attachments = this.get('attachments');
const firstAttachment = attachments && attachments[0];
const attachments = this.get('attachments') || [];
const firstAttachment = attachments[0];
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
@ -506,7 +506,9 @@
authorProfileName: contact.profileName,
authorPhoneNumber: contact.phoneNumber,
conversationType: isGroup ? 'group' : 'direct',
attachment: this.getPropsForAttachment(firstAttachment),
attachments: attachments.map(attachment =>
this.getPropsForAttachment(attachment)
),
quote: this.getPropsForQuote(),
authorAvatarPath,
isExpired: this.hasExpired,
@ -516,9 +518,9 @@
onRetrySend: () => this.retrySend(),
onShowDetail: () => this.trigger('show-message-detail', this),
onDelete: () => this.trigger('delete', this),
onClickAttachment: () =>
onClickAttachment: attachment =>
this.trigger('show-lightbox', {
attachment: firstAttachment,
attachment,
message: this,
}),

@ -289,18 +289,11 @@ SecretSessionCipher.prototype = {
signalProtocolStore,
destinationAddress
);
const sessionRecord = await sessionCipher.getRecord(
destinationAddress.toString()
);
const openSession = sessionRecord.getOpenSession();
if (!openSession) {
throw new Error('No active session');
}
const message = await sessionCipher.encrypt(paddedPlaintext);
const ourIdentity = await signalProtocolStore.getIdentityKeyPair();
const theirIdentity = fromEncodedBinaryToArrayBuffer(
openSession.indexInfo.remoteIdentityKey
await signalProtocolStore.loadIdentityKey(destinationAddress.getName())
);
const ephemeral = await libsignal.Curve.async.generateKeyPair();

@ -9,7 +9,6 @@ const IndexedDB = require('./indexeddb');
const Notifications = require('../../ts/notifications');
const OS = require('../../ts/OS');
const Settings = require('./settings');
const Startup = require('./startup');
const Util = require('../../ts/util');
const { migrateToSQL } = require('./migrate_to_sql');
const Metadata = require('./metadata/SecretSessionCipher');
@ -58,6 +57,9 @@ const {
const {
TimerNotification,
} = require('../../ts/components/conversation/TimerNotification');
const {
TypingBubble,
} = require('../../ts/components/conversation/TypingBubble');
const {
VerificationNotification,
} = require('../../ts/components/conversation/VerificationNotification');
@ -195,6 +197,7 @@ exports.setup = (options = {}) => {
Types: {
Message: MediaGalleryMessage,
},
TypingBubble,
VerificationNotification,
};
@ -233,7 +236,6 @@ exports.setup = (options = {}) => {
OS,
RefreshSenderCertificate,
Settings,
Startup,
Types,
Util,
Views,

@ -1,65 +0,0 @@
const is = require('@sindresorhus/is');
const Errors = require('./types/errors');
const Settings = require('./settings');
exports.syncReadReceiptConfiguration = async ({
ourNumber,
deviceId,
sendRequestConfigurationSyncMessage,
storage,
prepareForSend,
}) => {
if (!is.string(deviceId)) {
throw new TypeError('deviceId is required');
}
if (!is.function(sendRequestConfigurationSyncMessage)) {
throw new TypeError('sendRequestConfigurationSyncMessage is required');
}
if (!is.function(prepareForSend)) {
throw new TypeError('prepareForSend is required');
}
if (!is.string(ourNumber)) {
throw new TypeError('ourNumber is required');
}
if (!is.object(storage)) {
throw new TypeError('storage is required');
}
const isPrimaryDevice = deviceId === '1';
if (isPrimaryDevice) {
return {
status: 'skipped',
reason: 'isPrimaryDevice',
};
}
const settingName = Settings.READ_RECEIPT_CONFIGURATION_SYNC;
const hasPreviouslySynced = Boolean(storage.get(settingName));
if (hasPreviouslySynced) {
return {
status: 'skipped',
reason: 'hasPreviouslySynced',
};
}
try {
const { wrap, sendOptions } = prepareForSend(ourNumber, {
syncMessage: true,
});
await wrap(sendRequestConfigurationSyncMessage(sendOptions));
storage.put(settingName, true);
} catch (error) {
return {
status: 'error',
reason: 'failedToSendSyncMessage',
error: Errors.toLogFormat(error),
};
}
return {
status: 'complete',
};
};

@ -702,6 +702,7 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
messageArray,
timestamp,
silent,
online,
{ accessKey } = {}
) {
const jsonData = { messages: messageArray, timestamp };
@ -709,6 +710,9 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}
return _ajax({
call: 'messages',
@ -721,12 +725,21 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
});
}
function sendMessages(destination, messageArray, timestamp, silent) {
function sendMessages(
destination,
messageArray,
timestamp,
silent,
online
) {
const jsonData = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}
return _ajax({
call: 'messages',

@ -1,12 +1,15 @@
/* global $: false */
/* global _: false */
/* global emojiData: false */
/* global EmojiPanel: false */
/* global extension: false */
/* global i18n: false */
/* global Signal: false */
/* global storage: false */
/* global Whisper: false */
/* global
$,
_,
emojiData,
EmojiPanel,
extension,
i18n,
Signal,
storage,
Whisper,
ConversationController
*/
// eslint-disable-next-line func-names
(function () {
@ -83,6 +86,7 @@
this.listenTo(this.model, 'prune', this.onPrune);
this.listenTo(this.model, 'disable:input', this.onDisableInput);
this.listenTo(this.model, 'change:placeholder', this.onChangePlaceholder);
this.listenTo(this.model, 'typing-update', this.renderTypingBubble);
this.listenTo(
this.model.messageCollection,
'show-identity',
@ -262,6 +266,7 @@
'submit .send': 'checkUnverifiedSendMessage',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'keyup .send-message': 'maybeBumpTyping',
click: 'onClick',
'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio',
@ -471,6 +476,43 @@
}
},
renderTypingBubble() {
const timers = this.model.contactTypingTimers || {};
const records = _.values(timers);
const mostRecent = _.first(_.sortBy(records, 'timestamp'));
if (!mostRecent && this.typingBubbleView) {
this.typingBubbleView.remove();
this.typingBubbleView = null;
}
if (!mostRecent) {
return;
}
const { sender } = mostRecent;
const contact = ConversationController.getOrCreate(sender, 'private');
const props = {
...contact.format(),
conversationType: this.model.isPrivate() ? 'direct' : 'group',
};
if (this.typingBubbleView) {
this.typingBubbleView.update(props);
return;
}
this.typingBubbleView = new Whisper.ReactWrapperView({
className: 'message-wrapper typing-bubble-wrapper',
Component: Signal.Components.TypingBubble,
props,
});
this.typingBubbleView.$el.appendTo(this.$('.typing-container'));
if (this.view.atBottom()) {
this.typingBubbleView.el.scrollIntoView();
}
},
toggleMicrophone() {
// ALWAYS HIDE until we support audio
this.$('.capture-audio').hide();
@ -593,6 +635,7 @@
this.view.resetScrollPosition();
this.$el.trigger('force-resize');
this.focusMessageField();
this.renderTypingBubble();
if (this.inProgressFetch) {
// eslint-disable-next-line more/no-then
@ -719,7 +762,7 @@
MessageCollection: Whisper.MessageCollection,
}
);
const documents = await Signal.Data.getMessagesWithFileAttachments(
const rawDocuments = await Signal.Data.getMessagesWithFileAttachments(
conversationId,
{
limit: DEFAULT_DOCUMENTS_FETCH_COUNT,
@ -743,24 +786,39 @@
}
}
const media = rawMedia.map(mediaMessage => {
const { attachments } = mediaMessage;
const first = attachments && attachments[0];
const { thumbnail } = first;
const media = _.flatten(
rawMedia.map(message => {
const { attachments } = message;
return (attachments || []).map((attachment, index) => {
const { thumbnail } = attachment;
return {
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path)
: null,
contentType: attachment.contentType,
index,
attachment,
message,
};
});
})
);
// Unlike visual media, only one non-image attachment is supported
const documents = rawDocuments.map(message => {
const attachments = message.attachments || [];
const attachment = attachments[0];
return {
...mediaMessage,
thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path)
: null,
objectURL: getAbsoluteAttachmentPath(
mediaMessage.attachments[0].path
),
contentType: attachment.contentType,
index: 0,
attachment,
message,
};
});
const saveAttachment = async ({ message } = {}) => {
const attachment = message.attachments[0];
const saveAttachment = async ({ attachment, message } = {}) => {
const timestamp = message.received_at;
Signal.Types.Attachment.save({
attachment,
@ -770,22 +828,22 @@
});
};
const onItemClick = async ({ message, type }) => {
const onItemClick = async ({ message, attachment, type }) => {
switch (type) {
case 'documents': {
saveAttachment({ message });
saveAttachment({ message, attachment });
break;
}
case 'media': {
const selectedIndex = media.findIndex(
mediaMessage => mediaMessage.id === message.id
mediaMessage => mediaMessage.attachment.path === attachment.path
);
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.LightboxGallery,
props: {
messages: media,
media,
onSave: saveAttachment,
selectedIndex,
},
@ -1158,18 +1216,56 @@
return;
}
const attachments = message.get('attachments') || [];
if (attachments.length === 1) {
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);
return;
}
const selectedIndex = _.findIndex(
attachments,
item => attachment.path === item.path
);
const media = attachments.map((item, index) => ({
objectURL: getAbsoluteAttachmentPath(item.path),
contentType: item.contentType,
index,
message,
attachment: item,
}));
const onSave = async (options = {}) => {
Signal.Types.Attachment.save({
attachment: options.attachment,
document,
getAbsolutePath: getAbsoluteAttachmentPath,
timestamp: options.message.received_at,
});
};
const props = {
objectURL: getAbsoluteAttachmentPath(path),
contentType,
onSave: () => this.downloadAttachment({ attachment, message }),
media,
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
onSave,
};
this.lightboxView = new Whisper.ReactWrapperView({
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.Lightbox,
Component: Signal.Components.LightboxGallery,
props,
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
},
showMessageDetail(message) {
@ -1494,6 +1590,7 @@
async sendMessage(e) {
this.removeLastSeenIndicator();
this.closeEmojiPanel();
this.model.clearTypingTimers();
let toast;
if (extension.expired()) {
@ -1545,6 +1642,15 @@
}
},
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping() {
const messageText = this.$messageField.val();
if (messageText.length) {
this.model.throttledBumpTyping();
}
},
updateMessageFieldSize(event) {
const keyCode = event.which || event.keyCode;

@ -1,4 +1,4 @@
/* global Whisper, _ */
/* global Whisper, Backbone, _, $ */
// eslint-disable-next-line func-names
(function() {
@ -6,15 +6,36 @@
window.Whisper = window.Whisper || {};
Whisper.MessageListView = Whisper.ListView.extend({
Whisper.MessageListView = Backbone.View.extend({
tagName: 'ul',
className: 'message-list',
template: $('#message-list').html(),
itemView: Whisper.MessageView,
events: {
scroll: 'onScroll',
},
// Here we reimplement Whisper.ListView so we can override addAll
render() {
this.addAll();
return this;
},
// The key is that we don't erase all inner HTML, we re-render our template.
// And then we keep a reference to .messages
addAll() {
Whisper.View.prototype.render.call(this);
this.$messages = this.$('.messages');
this.collection.each(this.addOne, this);
},
initialize() {
Whisper.ListView.prototype.initialize.call(this);
this.listenTo(this.collection, 'add', this.addOne);
this.listenTo(this.collection, 'reset', this.addAll);
this.render();
this.triggerLazyScroll = _.debounce(() => {
this.$el.trigger('lazyScroll');
@ -78,10 +99,10 @@
if (index === this.collection.length - 1) {
// add to the bottom.
this.$el.append(view.el);
this.$messages.append(view.el);
} else if (index === 0) {
// add to top
this.$el.prepend(view.el);
this.$messages.prepend(view.el);
} else {
// insert
const next = this.$(`#${this.collection.at(index + 1).id}`);
@ -92,7 +113,7 @@
view.$el.insertAfter(prev);
} else {
// scan for the right spot
const elements = this.$el.children();
const elements = this.$messages.children();
if (elements.length > 0) {
for (let i = 0; i < elements.length; i += 1) {
const m = this.collection.get(elements[i].id);
@ -103,7 +124,7 @@
}
}
} else {
this.$el.append(view.el);
this.$messages.append(view.el);
}
}
}

@ -90,10 +90,10 @@
return verifyMAC(ivAndCiphertext, macKey, mac, 32)
.then(() => {
if (theirDigest !== null) {
return verifyDigest(encryptedBin, theirDigest);
if (!theirDigest) {
throw new Error('Failure: Ask sender to update Signal and resend.');
}
return null;
return verifyDigest(encryptedBin, theirDigest);
})
.then(() => decrypt(aesKey, ciphertext, iv));
},

@ -35837,7 +35837,7 @@ SessionBuilder.prototype = {
record.updateSessionState(session);
return Promise.all([
this.storage.storeSession(address, record.serialize()),
this.storage.saveIdentity(this.remoteAddress.toString(), session.indexInfo.remoteIdentityKey)
this.storage.saveIdentity(this.remoteAddress.toString(), device.identityKey)
]);
}.bind(this));
}.bind(this));
@ -36080,9 +36080,12 @@ SessionCipher.prototype = {
msg.ciphertext = ciphertext;
var encodedMsg = msg.toArrayBuffer();
var ourIdentityKeyBuffer = util.toArrayBuffer(ourIdentityKey.pubKey);
var theirIdentityKey = util.toArrayBuffer(session.indexInfo.remoteIdentityKey);
var macInput = new Uint8Array(encodedMsg.byteLength + 33*2 + 1);
macInput.set(new Uint8Array(util.toArrayBuffer(ourIdentityKey.pubKey)));
macInput.set(new Uint8Array(util.toArrayBuffer(session.indexInfo.remoteIdentityKey)), 33);
macInput.set(new Uint8Array(ourIdentityKeyBuffer));
macInput.set(new Uint8Array(theirIdentityKey), 33);
macInput[33*2] = (3 << 4) | 3;
macInput.set(new Uint8Array(encodedMsg), 33*2 + 1);
@ -36093,13 +36096,13 @@ SessionCipher.prototype = {
result.set(new Uint8Array(mac, 0, 8), encodedMsg.byteLength + 1);
return this.storage.isTrustedIdentity(
this.remoteAddress.getName(), util.toArrayBuffer(session.indexInfo.remoteIdentityKey), this.storage.Direction.SENDING
this.remoteAddress.getName(), theirIdentityKey, this.storage.Direction.SENDING
).then(function(trusted) {
if (!trusted) {
throw new Error('Identity key changed');
}
}).then(function() {
return this.storage.saveIdentity(this.remoteAddress.toString(), session.indexInfo.remoteIdentityKey);
return this.storage.saveIdentity(this.remoteAddress.toString(), theirIdentityKey);
}.bind(this)).then(function() {
record.updateSessionState(session);
return this.storage.storeSession(address, record.serialize()).then(function() {

@ -390,7 +390,7 @@ MessageReceiver.prototype.extend({
window.log.info('getAllFromCache');
const count = await textsecure.storage.unprocessed.getCount();
if (count > 250) {
if (count > 1500) {
await textsecure.storage.unprocessed.removeAll();
window.log.warn(
`There were ${count} messages in cache. Deleted all instead of reprocessing`
@ -719,12 +719,14 @@ MessageReceiver.prototype.extend({
// Here we take this sender information and attach it back to the envelope
// to make the rest of the app work properly.
const originalSource = envelope.source;
// eslint-disable-next-line no-param-reassign
envelope.source = sender.getName();
// eslint-disable-next-line no-param-reassign
envelope.sourceDevice = sender.getDeviceId();
// eslint-disable-next-line no-param-reassign
envelope.unidentifiedDeliveryReceived = true;
envelope.unidentifiedDeliveryReceived = !originalSource;
// Return just the content because that matches the signature of the other
// decrypt methods used above.
@ -734,12 +736,14 @@ MessageReceiver.prototype.extend({
const { sender } = error || {};
if (sender) {
const originalSource = envelope.source;
// eslint-disable-next-line no-param-reassign
envelope.source = sender.getName();
// eslint-disable-next-line no-param-reassign
envelope.sourceDevice = sender.getDeviceId();
// eslint-disable-next-line no-param-reassign
envelope.unidentifiedDeliveryReceived = true;
envelope.unidentifiedDeliveryReceived = !originalSource;
throw error;
}
@ -966,6 +970,8 @@ MessageReceiver.prototype.extend({
return this.handleCallMessage(envelope, content.callMessage);
if (content.receiptMessage)
return this.handleReceiptMessage(envelope, content.receiptMessage);
if (content.typingMessage)
return this.handleTypingMessage(envelope, content.typingMessage);
// Trigger conversation friend request event for empty message
const conversation = window.ConversationController.get(envelope.source);
@ -1011,6 +1017,43 @@ MessageReceiver.prototype.extend({
}
return Promise.all(results);
},
handleTypingMessage(envelope, typingMessage) {
const ev = new Event('typing');
this.removeFromCache(envelope);
if (envelope.timestamp && typingMessage.timestamp) {
const envelopeTimestamp = envelope.timestamp.toNumber();
const typingTimestamp = typingMessage.timestamp.toNumber();
if (typingTimestamp !== envelopeTimestamp) {
window.log.warn(
`Typing message envelope timestamp (${envelopeTimestamp}) did not match typing timestamp (${typingTimestamp})`
);
return null;
}
}
ev.sender = envelope.source;
ev.senderDevice = envelope.sourceDevice;
ev.typing = {
typingMessage,
timestamp: typingMessage.timestamp
? typingMessage.timestamp.toNumber()
: Date.now(),
groupId: typingMessage.groupId
? typingMessage.groupId.toString('binary')
: null,
started:
typingMessage.action ===
textsecure.protobuf.TypingMessage.Action.STARTED,
stopped:
typingMessage.action ===
textsecure.protobuf.TypingMessage.Action.STOPPED,
};
return this.dispatchEvent(ev);
},
handleNullMessage(envelope) {
window.log.info('null message from', this.getEnvelopeId(envelope));
this.removeFromCache(envelope);
@ -1382,7 +1425,15 @@ MessageReceiver.prototype.extend({
);
}
for (let i = 0, max = decrypted.attachments.length; i < max; i += 1) {
const attachmentCount = decrypted.attachments.length;
const ATTACHMENT_MAX = 32;
if (attachmentCount > ATTACHMENT_MAX) {
throw new Error(
`Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
);
}
for (let i = 0; i < attachmentCount; i += 1) {
const attachment = decrypted.attachments[i];
promises.push(this.handleAttachment(attachment));
}

@ -43,11 +43,11 @@ function OutgoingMessage(
this.failoverNumbers = [];
this.unidentifiedDeliveries = [];
const { numberInfo, senderCertificate, messageType } = options;
const { numberInfo, senderCertificate, online, messageType } = options;
this.numberInfo = numberInfo;
this.senderCertificate = senderCertificate;
this.messageType =
messageType || 'outgoing';
this.online = online;
this.messageType = messageType || 'outgoing';
}
OutgoingMessage.prototype = {

@ -1,4 +1,4 @@
/* global textsecure, WebAPI, libsignal, OutgoingMessage, window */
/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window */
/* eslint-disable more/no-then, no-bitwise */
@ -323,10 +323,31 @@ MessageSender.prototype = {
});
},
sendMessageProtoAndWait(timestamp, numbers, message, silent, options = {}) {
return new Promise((resolve, reject) => {
const callback = result => {
if (result && result.errors && result.errors.length > 0) {
return reject(result);
}
return resolve(result);
};
this.sendMessageProto(
timestamp,
numbers,
message,
callback,
silent,
options
);
});
},
sendIndividualProto(number, proto, timestamp, silent, options = {}) {
return new Promise((resolve, reject) => {
const callback = res => {
if (res.errors.length > 0) {
if (res && res.errors && res.errors.length > 0) {
reject(res);
} else {
resolve(res);
@ -454,6 +475,7 @@ MessageSender.prototype = {
return Promise.resolve();
},
sendRequestGroupSyncMessage(options) {
const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId();
@ -501,6 +523,55 @@ MessageSender.prototype = {
return Promise.resolve();
},
async sendTypingMessage(options = {}, sendOptions = {}) {
const ACTION_ENUM = textsecure.protobuf.TypingMessage.Action;
const { recipientId, groupId, isTyping, timestamp } = options;
// We don't want to send typing messages to our other devices, but we will
// in the group case.
const myNumber = textsecure.storage.user.getNumber();
if (recipientId && myNumber === recipientId) {
return null;
}
if (!recipientId && !groupId) {
throw new Error('Need to provide either recipientId or groupId!');
}
const recipients = groupId
? _.without(await textsecure.storage.groups.getNumbers(groupId), myNumber)
: [recipientId];
const groupIdBuffer = groupId
? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(groupId)
: null;
const action = isTyping ? ACTION_ENUM.STARTED : ACTION_ENUM.STOPPED;
const finalTimestamp = timestamp || Date.now();
const typingMessage = new textsecure.protobuf.TypingMessage();
typingMessage.groupId = groupIdBuffer;
typingMessage.action = action;
typingMessage.timestamp = finalTimestamp;
const contentMessage = new textsecure.protobuf.Content();
contentMessage.typingMessage = typingMessage;
const silent = true;
const online = true;
return this.sendMessageProtoAndWait(
finalTimestamp,
recipients,
contentMessage,
silent,
{
...sendOptions,
online,
}
);
},
sendDeliveryReceipt(recipientId, timestamp, options) {
const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId();
@ -524,6 +595,7 @@ MessageSender.prototype = {
options
);
},
sendReadReceipts(sender, timestamps, options) {
const receiptMessage = new textsecure.protobuf.ReceiptMessage();
receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ;
@ -992,6 +1064,7 @@ textsecure.MessageSender = function MessageSenderWrapper(
this.sendMessage = sender.sendMessage.bind(sender);
this.resetSession = sender.resetSession.bind(sender);
this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender);
this.sendTypingMessage = sender.sendTypingMessage.bind(sender);
this.createGroup = sender.createGroup.bind(sender);
this.updateGroup = sender.updateGroup.bind(sender);
this.addNumberToGroup = sender.addNumberToGroup.bind(sender);

@ -28,7 +28,11 @@
ourNumber,
{ syncMessage: true }
);
window.log.info('SyncRequest created. Sending contact sync message...');
window.log.info('SyncRequest created. Sending config sync request...');
wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));
window.log.info('SyncRequest now sending contact sync message...');
wrap(sender.sendRequestContactSyncMessage(sendOptions))
.then(() => {
window.log.info('SyncRequest now sending group sync messsage...');

@ -1107,9 +1107,14 @@ function getDataFromMainWindow(name, callback) {
function installSettingsGetter(name) {
ipc.on(`get-${name}`, event => {
if (mainWindow && mainWindow.webContents) {
getDataFromMainWindow(name, (error, value) =>
event.sender.send(`get-success-${name}`, error, value)
);
getDataFromMainWindow(name, (error, value) => {
const contents = event.sender;
if (contents.isDestroyed()) {
return;
}
contents.send(`get-success-${name}`, error, value);
});
}
});
}
@ -1117,9 +1122,14 @@ function installSettingsGetter(name) {
function installSettingsSetter(name) {
ipc.on(`set-${name}`, (event, value) => {
if (mainWindow && mainWindow.webContents) {
ipc.once(`set-success-${name}`, (_event, error) =>
event.sender.send(`set-success-${name}`, error)
);
ipc.once(`set-success-${name}`, (_event, error) => {
const contents = event.sender;
if (contents.isDestroyed()) {
return;
}
contents.send(`set-success-${name}`, error);
});
mainWindow.webContents.send(`set-${name}`, value);
}
});

@ -3,7 +3,7 @@
"productName": "Loki Messenger",
"description": "Private messaging from your desktop",
"repository": "https://github.com/sloki-project/loki-messenger.git",
"version": "1.18.1",
"version": "1.19.0",
"license": "GPL-3.0",
"author": {
"name": "Open Whisper Systems",
@ -46,118 +46,116 @@
},
"dependencies": {
"@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#ed4f4d179ac010c6347b291cbd4c2ebe5c773741",
"@sindresorhus/is": "^0.8.0",
"archiver": "^2.1.1",
"backbone": "^1.3.3",
"blob-util": "^1.3.0",
"blueimp-canvas-to-blob": "^3.14.0",
"blueimp-load-image": "^2.18.0",
"buffer-crc32": "^0.2.1",
"bunyan": "^1.8.12",
"classnames": "^2.2.5",
"config": "^1.28.1",
"electron-editor-context-menu": "^1.1.1",
"electron-is-dev": "^0.3.0",
"electron-unhandled": "https://github.com/scottnonnenberg-signal/electron-unhandled.git#7496187472aa561d39fcd4c843a54ffbef0a388c",
"electron-updater": "^2.21.10",
"@sindresorhus/is": "0.8.0",
"archiver": "2.1.1",
"backbone": "1.3.3",
"blob-util": "1.3.0",
"blueimp-canvas-to-blob": "3.14.0",
"blueimp-load-image": "2.18.0",
"buffer-crc32": "0.2.13",
"bunyan": "1.8.12",
"classnames": "2.2.5",
"config": "1.28.1",
"electron-editor-context-menu": "1.1.1",
"electron-is-dev": "0.3.0",
"electron-updater": "2.21.10",
"emoji-datasource": "4.0.0",
"emoji-datasource-apple": "4.0.0",
"emoji-js": "^3.4.0",
"emoji-js": "3.4.0",
"emoji-panel": "https://github.com/scottnonnenberg-signal/emoji-panel.git#v0.5.5",
"filesize": "^3.6.1",
"firstline": "^1.2.1",
"form-data": "^2.3.2",
"fs-extra": "^5.0.0",
"glob": "^7.1.2",
"google-libphonenumber": "^3.0.7",
"got": "^8.2.0",
"identicon.js": "^2.3.3",
"intl-tel-input": "^12.1.15",
"jquery": "^3.3.1",
"js-sha512": "^0.8.0",
"jsbn": "^1.1.0",
"linkify-it": "^2.0.3",
"lodash": "^4.17.4",
"mkdirp": "^0.5.1",
"moment": "^2.21.0",
"mustache": "^2.3.0",
"node-fetch": "^2.2.0",
"node-gyp": "^3.8.0",
"node-sass": "^4.9.3",
"os-locale": "^2.1.0",
"pify": "^3.0.0",
"filesize": "3.6.1",
"firstline": "1.2.1",
"form-data": "2.3.2",
"fs-extra": "5.0.0",
"glob": "7.1.2",
"google-libphonenumber": "3.0.7",
"got": "8.2.0",
"identicon.js": "2.3.3",
"intl-tel-input": "12.1.15",
"jquery": "3.3.1",
"js-sha512": "0.8.0",
"jsbn": "1.1.0",
"linkify-it": "2.0.3",
"lodash": "4.17.11",
"mkdirp": "0.5.1",
"moment": "2.21.0",
"mustache": "2.3.0",
"node-fetch": "2.3.0",
"node-gyp": "3.8.0",
"node-sass": "4.9.3",
"os-locale": "2.1.0",
"pify": "3.0.0",
"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",
"semver": "^5.4.1",
"spellchecker": "^3.4.4",
"testcheck": "^1.0.0-rc.2",
"tmp": "^0.0.33",
"to-arraybuffer": "^1.0.1",
"underscore": "^1.9.0",
"uuid": "^3.3.2",
"websocket": "^1.0.25"
"proxy-agent": "3.0.3",
"react": "16.2.0",
"react-contextmenu": "2.9.2",
"react-dom": "16.2.0",
"read-last-lines": "1.3.0",
"rimraf": "2.6.2",
"semver": "5.4.1",
"spellchecker": "3.4.4",
"testcheck": "1.0.0-rc.2",
"tmp": "0.0.33",
"to-arraybuffer": "1.0.1",
"underscore": "1.9.0",
"uuid": "3.3.2",
"websocket": "1.0.28"
},
"devDependencies": {
"@types/chai": "^4.1.2",
"@types/classnames": "^2.2.3",
"@types/filesize": "^3.6.0",
"@types/google-libphonenumber": "^7.4.14",
"@types/jquery": "^3.3.1",
"@types/linkify-it": "^2.0.3",
"@types/lodash": "^4.14.106",
"@types/mocha": "^5.0.0",
"@types/qs": "^6.5.1",
"@types/react": "^16.3.1",
"@types/react-dom": "^16.0.4",
"@types/semver": "^5.5.0",
"@types/sinon": "^4.3.1",
"arraybuffer-loader": "^1.0.3",
"asar": "^0.14.0",
"bower": "^1.8.2",
"chai": "^4.1.2",
"electron": "2.0.8",
"@types/chai": "4.1.2",
"@types/classnames": "2.2.3",
"@types/filesize": "3.6.0",
"@types/google-libphonenumber": "7.4.14",
"@types/jquery": "3.3.1",
"@types/linkify-it": "2.0.3",
"@types/lodash": "4.14.106",
"@types/mocha": "5.0.0",
"@types/qs": "6.5.1",
"@types/react": "16.3.1",
"@types/react-dom": "16.0.4",
"@types/semver": "5.5.0",
"@types/sinon": "4.3.1",
"arraybuffer-loader": "1.0.3",
"asar": "0.14.0",
"bower": "1.8.2",
"chai": "4.1.2",
"electron": "3.0.9",
"electron-builder": "20.13.5",
"electron-icon-maker": "0.0.3",
"eslint": "^4.14.0",
"eslint-config-airbnb-base": "^12.1.0",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-mocha": "^4.12.1",
"eslint-plugin-more": "^0.3.1",
"extract-zip": "^1.6.6",
"grunt": "^1.0.1",
"grunt-cli": "^1.2.0",
"grunt-contrib-concat": "^1.0.1",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-watch": "^1.0.0",
"grunt-exec": "^3.0.0",
"grunt-gitinfo": "^0.1.7",
"grunt-sass": "^3.0.1",
"mocha": "^4.1.0",
"mocha-testcheck": "^1.0.0-rc.0",
"node-sass-import-once": "^1.2.0",
"nsp": "^3.2.1",
"nyc": "^11.4.1",
"eslint": "4.14.0",
"eslint-config-airbnb-base": "12.1.0",
"eslint-config-prettier": "2.9.0",
"eslint-plugin-import": "2.8.0",
"eslint-plugin-mocha": "4.12.1",
"eslint-plugin-more": "0.3.1",
"extract-zip": "1.6.6",
"grunt": "1.0.1",
"grunt-cli": "1.2.0",
"grunt-contrib-concat": "1.0.1",
"grunt-contrib-copy": "1.0.0",
"grunt-contrib-watch": "1.0.0",
"grunt-exec": "3.0.0",
"grunt-gitinfo": "0.1.7",
"grunt-sass": "3.0.1",
"mocha": "4.1.0",
"mocha-testcheck": "1.0.0-rc.0",
"node-sass-import-once": "1.2.0",
"nyc": "11.4.1",
"prettier": "1.12.0",
"qs": "^6.5.1",
"react-docgen-typescript": "^1.2.6",
"react-styleguidist": "^7.0.1",
"sinon": "^4.4.2",
"spectron": "^3.8.0",
"ts-loader": "^4.1.0",
"tslint": "^5.9.1",
"tslint-microsoft-contrib": "^5.0.3",
"tslint-react": "^3.5.1",
"typescript": "^2.8.1",
"webpack": "^4.4.1"
"qs": "6.5.1",
"react-docgen-typescript": "1.2.6",
"react-styleguidist": "7.0.1",
"sinon": "4.4.2",
"spectron": "5.0.0",
"ts-loader": "4.1.0",
"tslint": "5.9.1",
"tslint-microsoft-contrib": "5.0.3",
"tslint-react": "3.5.1",
"typescript": "2.8.1",
"webpack": "4.4.1"
},
"engines": {
"node": "^8.9.3"
"node": "10.13.0"
},
"build": {
"appId": "org.loki.messenger-desktop",
@ -270,6 +268,10 @@
"!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,thumbs.db,.gitignore,.gitattributes,.editorconfig,.flowconfig,.yarn-metadata.json,.idea,appveyor.yml,.travis.yml,circle.yml,npm-debug.log,.nyc_output,yarn.lock,.yarn-integrity}",
"node_modules/spellchecker/build/Release/*.node",
"node_modules/websocket/build/Release/*.node",
"node_modules/socks/build/*.js",
"node_modules/socks/build/common/*.js",
"node_modules/socks/build/client/*.js",
"node_modules/smart-buffer/build/*.js",
"!node_modules/@journeyapps/sqlcipher/deps/*"
]
}

@ -189,7 +189,7 @@ ipc.on('delete-all-data', () => {
});
ipc.on('get-ready-for-shutdown', async () => {
const { shutdown } = window.Events;
const { shutdown } = window.Events || {};
if (!shutdown) {
window.log.error('preload shutdown handler: shutdown method not found');
ipc.send('now-ready-for-shutdown');

@ -33,6 +33,7 @@ message Content {
optional CallMessage callMessage = 3;
optional NullMessage nullMessage = 4;
optional ReceiptMessage receiptMessage = 5;
optional TypingMessage typingMessage = 6;
optional PreKeyBundleMessage preKeyBundleMessage = 101;
}
@ -193,6 +194,17 @@ message ReceiptMessage {
repeated uint64 timestamp = 2;
}
message TypingMessage {
enum Action {
STARTED = 0;
STOPPED = 1;
}
optional uint64 timestamp = 1;
optional Action action = 2;
optional bytes groupId = 3;
}
message Verified {
enum State {
DEFAULT = 0;
@ -254,6 +266,7 @@ message SyncMessage {
message Configuration {
optional bool readReceipts = 1;
optional bool unidentifiedDeliveryIndicators = 2;
optional bool typingIndicators = 3;
}
optional Sent sent = 1;
@ -282,6 +295,7 @@ message AttachmentPointer {
optional uint32 flags = 8;
optional uint32 width = 9;
optional uint32 height = 10;
optional string caption = 11;
}
message GroupContext {

@ -136,14 +136,14 @@
.message-list {
list-style: none;
.message-wrapper {
margin-left: 16px;
margin-right: 16px;
}
li {
margin-bottom: 10px;
.message-wrapper {
margin-left: 16px;
margin-right: 16px;
}
&::after {
visibility: hidden;
display: block;
@ -158,12 +158,16 @@
.group {
.message-container,
.message-list {
li .message-wrapper {
.message-wrapper {
margin-left: 44px;
}
}
}
.typing-bubble-wrapper {
margin-bottom: 20px;
}
.contact-detail-pane {
overflow-y: scroll;
padding-top: 40px;

@ -215,6 +215,8 @@
background-color: $color-conversation-blue_grey;
}
// START
.module-message__attachment-container {
// Entirely to ensure that images are centered if they aren't full width of bubble
text-align: center;
@ -244,97 +246,13 @@
border-top-right-radius: 0px;
}
.module-message__img-border-overlay {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
left: 0;
right: 0;
border-radius: 16px;
box-shadow: inset 0px 0px 0px 1px $color-black-015;
}
.module-message__img-border-overlay--with-content-below {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.module-message__img-border-overlay--with-content-above {
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
.module-message__img-attachment {
object-fit: cover;
width: 100%;
min-width: 200px;
min-height: 150px;
max-height: 300px;
// The padding on the bottom of the bubble produces three extra pixels of space at the
// bottom, so this doesn't match up with the padding numbers above.
margin-bottom: -3px;
// redundant with attachment-container, but we get cursor flashing on move otherwise
cursor: pointer;
}
.module-message__img-overlay {
height: 48px;
background-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 9%,
rgba(0, 0, 0, 0.02) 17%,
rgba(0, 0, 0, 0.05) 24%,
rgba(0, 0, 0, 0.08) 31%,
rgba(0, 0, 0, 0.12) 37%,
rgba(0, 0, 0, 0.16) 44%,
rgba(0, 0, 0, 0.2) 50%,
rgba(0, 0, 0, 0.24) 56%,
rgba(0, 0, 0, 0.28) 63%,
rgba(0, 0, 0, 0.32) 69%,
rgba(0, 0, 0, 0.35) 76%,
rgba(0, 0, 0, 0.38) 83%,
rgba(0, 0, 0, 0.4) 91%,
rgba(0, 0, 0, 0.4)
);
position: absolute;
bottom: 0;
z-index: 2;
left: 0;
right: 0;
margin-left: -12px;
margin-right: -12px;
margin-bottom: -10px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.module-message__video-overlay__circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background-color: $color-white;
border-radius: 24px;
}
.module-message__video-overlay__play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
@include color-svg('../images/play.svg', $color-signal-blue);
}
.module-message__audio-attachment {
margin-top: 2px;
}
@ -602,11 +520,18 @@
.module-message__author-avatar {
position: absolute;
// This accounts for the weird extra 3px we get at the bottom of messages
bottom: -3px;
bottom: 0px;
right: calc(100% + 4px);
}
.module-message__typing-container {
height: 16px;
display: flex;
flex-direction: row;
align-items: center;
}
// Module: Expire Timer
.module-expire-timer {
@ -1954,8 +1879,6 @@
display: flex;
flex-direction: row;
align-items: center;
margin-top: 3px;
}
.module-conversation-list-item__message__text {
@ -2265,6 +2188,237 @@
flex: 1;
}
// Module: Image
.module-image {
overflow: hidden;
background-color: $color-white;
position: relative;
display: inline-block;
margin: 1px;
}
.module-image__caption-icon {
position: absolute;
top: 6px;
left: 6px;
}
.module-image--curved-top-left {
border-top-left-radius: 16px;
}
.module-image--curved-top-right {
border-top-right-radius: 16px;
}
.module-image--curved-bottom-left {
border-bottom-left-radius: 16px;
}
.module-image--curved-bottom-right {
border-bottom-right-radius: 16px;
}
.module-image__border-overlay {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
left: 0;
right: 0;
box-shadow: inset 0px 0px 0px 1px $color-black-015;
}
.module-image__border-overlay--dark {
background-color: $color-black-02;
}
.module-image__image {
object-fit: cover;
// redundant with attachment-container, but we get cursor flashing on move otherwise
cursor: pointer;
margin-bottom: -3px;
}
.module-image__bottom-overlay {
height: 48px;
background-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 9%,
rgba(0, 0, 0, 0.02) 17%,
rgba(0, 0, 0, 0.05) 24%,
rgba(0, 0, 0, 0.08) 31%,
rgba(0, 0, 0, 0.12) 37%,
rgba(0, 0, 0, 0.16) 44%,
rgba(0, 0, 0, 0.2) 50%,
rgba(0, 0, 0, 0.24) 56%,
rgba(0, 0, 0, 0.28) 63%,
rgba(0, 0, 0, 0.32) 69%,
rgba(0, 0, 0, 0.35) 76%,
rgba(0, 0, 0, 0.38) 83%,
rgba(0, 0, 0, 0.4) 91%,
rgba(0, 0, 0, 0.4)
);
position: absolute;
bottom: 0;
z-index: 2;
left: 0;
right: 0;
}
.module-image__play-overlay__circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background-color: $color-white;
border-radius: 24px;
}
.module-image__play-overlay__icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
@include color-svg('../images/play.svg', $color-signal-blue);
}
.module-image__text-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
color: $color-white;
font-size: 20px;
font-weight: normal;
letter-spacing: 0;
text-align: center;
}
// Module: Image Grid
.module-image-grid {
display: inline-flex;
flex-direction: row;
align-items: center;
margin: -1px;
}
.module-image-grid--one-image {
margin-bottom: -5px;
}
.module-image-grid__column {
display: inline-flex;
flex-direction: column;
align-items: center;
}
.module-image-grid__row {
display: inline-flex;
flex-direction: row;
align-items: center;
flex-grow: 1;
}
// Module: Typing Animation
.module-typing-animation {
display: inline-flex;
flex-directin: row;
align-items: center;
height: 8px;
width: 38px;
padding-left: 1px;
padding-right: 1px;
}
.module-typing-animation__dot {
border-radius: 50%;
background-color: $color-gray-60;
height: 6px;
width: 6px;
opacity: 0.4;
}
.module-typing-animation__dot--light {
border-radius: 50%;
background-color: $color-white;
height: 6px;
width: 6px;
opacity: 0.4;
}
@keyframes typing-animation-first {
0% {
opacity: 0.4;
}
20% {
transform: scale(1.3);
opacity: 1;
}
40% {
opacity: 0.4;
}
}
@keyframes typing-animation-second {
10% {
opacity: 0.4;
}
30% {
transform: scale(1.3);
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes typing-animation-third {
20% {
opacity: 0.4;
}
40% {
transform: scale(1.3);
opacity: 1;
}
60% {
opacity: 0.4;
}
}
.module-typing-animation__dot--first {
animation: typing-animation-first 1600ms ease infinite;
}
.module-typing-animation__dot--second {
animation: typing-animation-second 1600ms ease infinite;
}
.module-typing-animation__dot--third {
animation: typing-animation-third 1600ms ease infinite;
}
.module-typing-animation__spacer {
flex-grow: 1;
}
// Third-party module: react-contextmenu
.react-contextmenu {

@ -1415,6 +1415,16 @@ body.dark-theme {
color: $color-dark-05;
}
// Module: Typing Animation
.module-typing-animation__dot {
background-color: $color-white;
}
.module-typing-animation__dot--light {
background-color: $color-white;
}
// Third-party module: react-contextmenu
.react-contextmenu {

@ -76,14 +76,15 @@ InMemorySignalProtocolStore.prototype = {
return Promise.resolve(toString(identityKey) === toString(trusted));
},
loadIdentityKey(identifier) {
if (identifier === null || identifier === undefined)
if (identifier === null || identifier === undefined) {
throw new Error('Tried to get identity key for undefined/null key');
}
return Promise.resolve(this.get(`identityKey${identifier}`));
},
saveIdentity(identifier, identityKey) {
if (identifier === null || identifier === undefined)
if (identifier === null || identifier === undefined) {
throw new Error('Tried to put identity key for undefined/null key');
}
const address = libsignal.SignalProtocolAddress.fromString(identifier);
const existing = this.get(`identityKey${address.getName()}`);

@ -1,150 +0,0 @@
const sinon = require('sinon');
const { assert } = require('chai');
const Startup = require('../../js/modules/startup');
describe('Startup', () => {
const sandbox = sinon.createSandbox();
describe('syncReadReceiptConfiguration', () => {
afterEach(() => {
sandbox.restore();
});
it('should complete if user hasnt previously synced', async () => {
const ourNumber = '+15551234567';
const deviceId = '2';
const sendRequestConfigurationSyncMessage = sandbox.spy();
const storagePutSpy = sandbox.spy();
const storage = {
get(name) {
if (name !== 'read-receipt-configuration-sync') {
return true;
}
return false;
},
put: storagePutSpy,
};
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const expected = {
status: 'complete',
};
const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId,
sendRequestConfigurationSyncMessage,
storage,
prepareForSend,
});
assert.deepEqual(actual, expected);
assert.equal(sendRequestConfigurationSyncMessage.callCount, 1);
assert.equal(storagePutSpy.callCount, 1);
assert(storagePutSpy.calledWith('read-receipt-configuration-sync', true));
});
it('should be skipped if this is the primary device', async () => {
const ourNumber = '+15551234567';
const deviceId = '1';
const sendRequestConfigurationSyncMessage = () => { };
const storage = {};
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const expected = {
status: 'skipped',
reason: 'isPrimaryDevice',
};
const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId,
sendRequestConfigurationSyncMessage,
storage,
prepareForSend,
});
assert.deepEqual(actual, expected);
});
it('should be skipped if user has previously synced', async () => {
const ourNumber = '+15551234567';
const deviceId = '2';
const sendRequestConfigurationSyncMessage = () => { };
const storage = {
get(name) {
if (name !== 'read-receipt-configuration-sync') {
return false;
}
return true;
},
};
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const expected = {
status: 'skipped',
reason: 'hasPreviouslySynced',
};
const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId,
sendRequestConfigurationSyncMessage,
storage,
prepareForSend,
});
assert.deepEqual(actual, expected);
});
it('should return error if sending of sync request fails', async () => {
const ourNumber = '+15551234567';
const deviceId = '2';
const sendRequestConfigurationSyncMessage = sandbox.stub();
sendRequestConfigurationSyncMessage.rejects(new Error('boom'));
const storagePutSpy = sandbox.spy();
const storage = {
get(name) {
if (name !== 'read-receipt-configuration-sync') {
return true;
}
return false;
},
put: storagePutSpy,
};
const prepareForSend = () => ({
wrap: promise => promise,
sendOptions: {},
});
const actual = await Startup.syncReadReceiptConfiguration({
ourNumber,
deviceId,
sendRequestConfigurationSyncMessage,
storage,
prepareForSend,
});
assert.equal(actual.status, 'error');
assert.include(actual.error, 'boom');
assert.equal(sendRequestConfigurationSyncMessage.callCount, 1);
assert.equal(storagePutSpy.callCount, 0);
});
});
});

@ -112,6 +112,40 @@
</util.LeftPaneContext>
```
#### Is typing
```jsx
<util.LeftPaneContext theme={util.theme}>
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
conversationType={'direct'}
unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000}
isTyping={true}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
conversationType={'direct'}
unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000}
isTyping={true}
lastMessage={{
status: 'read',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
</util.LeftPaneContext>
```
#### Selected
#### With unread
```jsx

@ -5,6 +5,8 @@ import { Avatar } from './Avatar';
import { MessageBody } from './conversation/MessageBody';
import { Timestamp } from './conversation/Timestamp';
import { ContactName } from './conversation/ContactName';
import { TypingAnimation } from './conversation/TypingAnimation';
import { Localizer } from '../types/Util';
interface Props {
@ -19,6 +21,7 @@ interface Props {
unreadCount: number;
isSelected: boolean;
isTyping: boolean;
lastMessage?: {
status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
text: string;
@ -120,9 +123,9 @@ export class ConversationListItem extends React.Component<Props> {
}
public renderMessage() {
const { lastMessage, unreadCount, i18n } = this.props;
const { lastMessage, isTyping, unreadCount, i18n } = this.props;
if (!lastMessage) {
if (!lastMessage && !isTyping) {
return null;
}
@ -136,14 +139,18 @@ export class ConversationListItem extends React.Component<Props> {
: null
)}
>
<MessageBody
text={lastMessage.text || ''}
disableJumbomoji={true}
disableLinks={true}
i18n={i18n}
/>
{isTyping ? (
<TypingAnimation i18n={i18n} />
) : (
<MessageBody
text={lastMessage && lastMessage.text ? lastMessage.text : ''}
disableJumbomoji={true}
disableLinks={true}
i18n={i18n}
/>
)}
</div>
{lastMessage.status ? (
{lastMessage && lastMessage.status ? (
<div
className={classNames(
'module-conversation-list-item__message__status-icon',

@ -1,4 +1,4 @@
## Image (supported format)
## Image
```js
const noop = () => {};
@ -13,6 +13,22 @@ const noop = () => {};
</div>;
```
## Image with caption
```js
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<Lightbox
objectURL="https://placekitten.com/800/600"
caption="This is the user-provided caption. We show it overlaid on the image. If it's really long, then it wraps, but it doesn't get too close to the edges of the image."
contentType="image/jpeg"
onSave={noop}
i18n={util.i18n}
/>
</div>;
```
## Image (unsupported format)
```js

@ -28,6 +28,7 @@ interface Props {
contentType: MIME.MIMEType | undefined;
i18n: Localizer;
objectURL: string;
caption?: string;
onNext?: () => void;
onPrevious?: () => void;
onSave?: () => void;
@ -57,6 +58,7 @@ const styles = {
paddingBottom: 0,
} as React.CSSProperties,
objectContainer: {
position: 'relative',
flexGrow: 1,
display: 'inline-flex',
justifyContent: 'center',
@ -68,6 +70,18 @@ const styles = {
maxHeight: '100%',
objectFit: 'contain',
} as React.CSSProperties,
caption: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
textAlign: 'center',
color: 'white',
padding: '1em',
paddingLeft: '3em',
paddingRight: '3em',
backgroundColor: 'rgba(192, 192, 192, .20)',
} as React.CSSProperties,
controlsOffsetPlaceholder: {
width: CONTROLS_WIDTH,
marginRight: CONTROLS_SPACING,
@ -194,6 +208,7 @@ export class Lightbox extends React.Component<Props> {
public render() {
const {
caption,
contentType,
objectURL,
onNext,
@ -215,6 +230,7 @@ export class Lightbox extends React.Component<Props> {
{!is.undefined(contentType)
? this.renderObject({ objectURL, contentType, i18n })
: null}
{caption ? <div style={styles.caption}>{caption}</div> : null}
</div>
<div style={styles.controls}>
<IconButton type="close" onClick={this.onClose} />
@ -273,6 +289,7 @@ export class Lightbox extends React.Component<Props> {
onClick={this.playVideoBound}
controls={true}
style={styles.object}
key={objectURL}
>
<source src={objectURL} />
</video>

@ -1,44 +1,70 @@
```js
const noop = () => {};
const messages = [
const mediaItems = [
{
objectURL: 'https://placekitten.com/799/600',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 1 },
attachment: {
contentType: 'image/jpeg',
caption:
"This is a really long caption. Because the user had a lot to say. You know, it's very important to provide full context when sending an image. You don't want to make the wrong impression.",
},
},
{
objectURL: 'https://placekitten.com/900/600',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 2 },
attachment: { contentType: 'image/jpeg' },
},
// Unsupported image type
{
objectURL: 'foo.tif',
attachments: [{ contentType: 'image/tiff' }],
contentType: 'image/tiff',
message: { id: 3 },
attachment: { contentType: 'image/tiff' },
},
// Video
{
objectURL: util.mp4ObjectUrl,
attachments: [{ contentType: 'video/mp4' }],
contentType: 'video/mp4',
message: { id: 4 },
attachment: { contentType: 'video/mp4' },
},
{
objectURL: util.mp4ObjectUrlV2,
contentType: 'video/mp4',
message: { id: 5 },
attachment: { contentType: 'video/mp4' },
},
{
objectURL: 'https://placekitten.com/980/800',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 6 },
attachment: { contentType: 'image/jpeg' },
},
{
objectURL: 'https://placekitten.com/656/540',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 7 },
attachment: { contentType: 'image/jpeg' },
},
{
objectURL: 'https://placekitten.com/762/400',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 8 },
attachment: { contentType: 'image/jpeg' },
},
{
objectURL: 'https://placekitten.com/920/620',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 9 },
attachment: { contentType: 'image/jpeg' },
},
];
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<LightboxGallery messages={messages} onSave={noop} i18n={util.i18n} />
<LightboxGallery media={mediaItems} onSave={noop} i18n={util.i18n} />
</div>;
```

@ -6,19 +6,26 @@ import React from 'react';
import * as MIME from '../types/MIME';
import { Lightbox } from './Lightbox';
import { Message } from './conversation/media-gallery/types/Message';
import { AttachmentType } from './conversation/types';
import { Localizer } from '../types/Util';
interface Item {
export interface MediaItemType {
objectURL?: string;
contentType: MIME.MIMEType | undefined;
thumbnailObjectUrl?: string;
contentType?: MIME.MIMEType;
index: number;
attachment: AttachmentType;
message: Message;
}
interface Props {
close: () => void;
i18n: Localizer;
messages: Array<Message>;
onSave?: ({ message }: { message: Message }) => void;
media: Array<MediaItemType>;
onSave?: (
{ attachment, message }: { attachment: AttachmentType; message: Message }
) => void;
selectedIndex: number;
}
@ -26,11 +33,6 @@ interface State {
selectedIndex: number;
}
const messageToItem = (message: Message): Item => ({
objectURL: message.objectURL,
contentType: message.attachments[0].contentType,
});
export class LightboxGallery extends React.Component<Props, State> {
public static defaultProps: Partial<Props> = {
selectedIndex: 0,
@ -45,20 +47,19 @@ export class LightboxGallery extends React.Component<Props, State> {
}
public render() {
const { close, messages, onSave, i18n } = this.props;
const { close, media, onSave, i18n } = this.props;
const { selectedIndex } = this.state;
const selectedMessage: Message = messages[selectedIndex];
const selectedItem = messageToItem(selectedMessage);
const selectedMedia = media[selectedIndex];
const firstIndex = 0;
const lastIndex = media.length - 1;
const onPrevious =
selectedIndex > firstIndex ? this.handlePrevious : undefined;
const lastIndex = messages.length - 1;
const onNext = selectedIndex < lastIndex ? this.handleNext : undefined;
const objectURL = selectedItem.objectURL || 'images/alert-outline.svg';
const objectURL = selectedMedia.objectURL || 'images/alert-outline.svg';
const { attachment } = selectedMedia;
return (
<Lightbox
@ -67,7 +68,8 @@ export class LightboxGallery extends React.Component<Props, State> {
onNext={onNext}
onSave={onSave ? this.handleSave : undefined}
objectURL={objectURL}
contentType={selectedItem.contentType}
caption={attachment ? attachment.caption : undefined}
contentType={selectedMedia.contentType}
i18n={i18n}
/>
);
@ -83,19 +85,21 @@ export class LightboxGallery extends React.Component<Props, State> {
this.setState((prevState, props) => ({
selectedIndex: Math.min(
prevState.selectedIndex + 1,
props.messages.length - 1
props.media.length - 1
),
}));
};
private handleSave = () => {
const { messages, onSave } = this.props;
const { media, onSave } = this.props;
if (!onSave) {
return;
}
const { selectedIndex } = this.state;
const message = messages[selectedIndex];
onSave({ message });
const mediaItem = media[selectedIndex];
const { attachment, message } = mediaItem;
onSave({ attachment, message });
};
}

@ -0,0 +1,122 @@
### Various sizes
```jsx
<Image height='200' width='199' url={util.pngObjectUrl} />
<Image height='149' width='149' url={util.pngObjectUrl} />
<Image height='99' width='99' url={util.pngObjectUrl} />
```
### Various curved corners
```jsx
<Image height='149' width='149' curveTopLeft url={util.pngObjectUrl} />
<Image height='149' width='149' curveTopRight url={util.pngObjectUrl} />
<Image height='149' width='149' curveBottomLeft url={util.pngObjectUrl} />
<Image height='149' width='149' curveBottomRight url={util.pngObjectUrl} />
```
### With bottom overlay
```jsx
<Image height='149' width='149' bottomOverlay url={util.pngObjectUrl} />
<Image height='149' width='149' bottomOverlay curveBottomRight url={util.pngObjectUrl} />
<Image height='149' width='149' bottomOverlay curveBottomLeft url={util.pngObjectUrl} />
```
### With play icon
```jsx
<Image height='200' width='199' playIconOverlay url={util.pngObjectUrl} />
<Image height='149' width='149' playIconOverlay url={util.pngObjectUrl} />
<Image height='99' width='99' playIconOverlay url={util.pngObjectUrl} />
```
### With dark overlay and text
```jsx
<div>
<div>
<Image height="200" width="199" darkOverlay url={util.pngObjectUrl} />
<Image height="149" width="149" darkOverlay url={util.pngObjectUrl} />
<Image height="99" width="99" darkOverlay url={util.pngObjectUrl} />
</div>
<hr />
<div>
<Image
height="200"
width="199"
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="149"
width="149"
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="99"
width="99"
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
</div>
</div>
```
### With caption
```jsx
<div>
<div>
<Image
height="200"
width="199"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
/>
<Image
height="149"
width="149"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
/>
<Image
height="99"
width="99"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
/>
</div>
<hr />
<div>
<Image
height="200"
width="199"
attachment={{ caption: 'dogs playing' }}
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="149"
width="149"
attachment={{ caption: 'dogs playing' }}
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="99"
width="99"
attachment={{ caption: 'dogs playing' }}
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
</div>
</div>
```

@ -0,0 +1,119 @@
import React from 'react';
import classNames from 'classnames';
import { Localizer } from '../../types/Util';
import { AttachmentType } from './types';
interface Props {
alt: string;
attachment: AttachmentType;
url: string;
height?: number;
width?: number;
overlayText?: string;
bottomOverlay?: boolean;
curveBottomLeft?: boolean;
curveBottomRight?: boolean;
curveTopLeft?: boolean;
curveTopRight?: boolean;
darkOverlay?: boolean;
playIconOverlay?: boolean;
i18n: Localizer;
onClick?: (attachment: AttachmentType) => void;
onError?: () => void;
}
export class Image extends React.Component<Props> {
public render() {
const {
alt,
attachment,
bottomOverlay,
curveBottomLeft,
curveBottomRight,
curveTopLeft,
curveTopRight,
darkOverlay,
height,
i18n,
onClick,
onError,
overlayText,
playIconOverlay,
url,
width,
} = this.props;
const { caption } = attachment || { caption: null };
return (
<div
onClick={() => {
if (onClick) {
onClick(attachment);
}
}}
role="button"
className={classNames(
'module-image',
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null
)}
>
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
{caption ? (
<img
className="module-image__caption-icon"
src="images/caption-shadow.svg"
alt={i18n('imageCaptionIconAlt')}
/>
) : null}
<div
className={classNames(
'module-image__border-overlay',
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null
)}
/>
{bottomOverlay ? (
<div
className={classNames(
'module-image__bottom-overlay',
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null
)}
/>
) : null}
{playIconOverlay ? (
<div className="module-image__play-overlay__circle">
<div className="module-image__play-overlay__icon" />
</div>
) : null}
{overlayText ? (
<div
className="module-image__text-container"
style={{ lineHeight: `${height}px` }}
>
{overlayText}
</div>
) : null}
</div>
);
}
}

@ -0,0 +1,354 @@
### One image
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### One image, various aspect ratios
```jsx
<div>
<ImageGrid
attachments={[
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapeObjectUrl,
contentType: 'image/png',
width: 4496,
height: 3000,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapeGreenObjectUrl,
contentType: 'image/png',
width: 1000,
height: 50,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapePurpleObjectUrl,
contentType: 'image/png',
width: 200,
height: 50,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.portraitYellowObjectUrl,
contentType: 'image/png',
width: 20,
height: 200,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapeRedObjectUrl,
contentType: 'image/png',
width: 300,
height: 1,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.portraitTealObjectUrl,
contentType: 'image/png',
width: 50,
height: 1000,
},
]}
i18n={util.i18n}
/>
</div>
```
### Two images
```jsx
const attachments = [
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### Three images
```jsx
const attachments = [
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### Four images
```jsx
const attachments = [
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### Five images
```jsx
const attachments = [
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### Six images
```
const attachments = [
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid withContentAbove withContentBelow attachments={attachments} i18n={util.i18n} />
</div>
</div>;
```

@ -0,0 +1,416 @@
import React from 'react';
import classNames from 'classnames';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
import { AttachmentType } from './types';
import { Image } from './Image';
import { Localizer } from '../../types/Util';
interface Props {
attachments: Array<AttachmentType>;
withContentAbove: boolean;
withContentBelow: boolean;
bottomOverlay?: boolean;
i18n: Localizer;
onError: () => void;
onClickAttachment?: (attachment: AttachmentType) => void;
}
const MAX_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5;
const MIN_WIDTH = 200;
const MIN_HEIGHT = 25;
export class ImageGrid extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length */
public render() {
const {
attachments,
bottomOverlay,
i18n,
onError,
onClickAttachment,
withContentAbove,
withContentBelow,
} = this.props;
const curveTopLeft = !Boolean(withContentAbove);
const curveTopRight = curveTopLeft;
const curveBottom = !Boolean(withContentBelow);
const curveBottomLeft = curveBottom;
const curveBottomRight = curveBottom;
if (!attachments || !attachments.length) {
return null;
}
if (attachments.length === 1) {
const { height, width } = getImageDimensions(attachments[0]);
return (
<div
className={classNames(
'module-image-grid',
'module-image-grid--one-image'
)}
>
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveTopLeft={curveTopLeft}
curveTopRight={curveTopRight}
curveBottomLeft={curveBottomLeft}
curveBottomRight={curveBottomRight}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={height}
width={width}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
);
}
if (attachments.length === 2) {
return (
<div className="module-image-grid">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
attachment={attachments[0]}
bottomOverlay={bottomOverlay && curveBottom}
curveTopLeft={curveTopLeft}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveTopRight={curveTopRight}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
);
}
if (attachments.length === 3) {
return (
<div className="module-image-grid">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveTopLeft={curveTopLeft}
curveBottomLeft={curveBottomLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={200}
width={199}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<div className="module-image-grid__column">
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
curveTopRight={curveTopRight}
height={99}
width={99}
attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomRight={curveBottomRight}
height={99}
width={99}
attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])}
url={getUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
</div>
);
}
if (attachments.length === 4) {
return (
<div className="module-image-grid">
<div className="module-image-grid__column">
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
curveTopLeft={curveTopLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[2])}
height={149}
width={149}
attachment={attachments[2]}
url={getUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[3])}
height={149}
width={149}
attachment={attachments[3]}
url={getUrl(attachments[3])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
</div>
</div>
);
}
return (
<div className="module-image-grid">
<div className="module-image-grid__column">
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
curveTopLeft={curveTopLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[2])}
height={99}
width={99}
attachment={attachments[2]}
url={getUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
playIconOverlay={isVideoAttachment(attachments[3])}
height={99}
width={98}
attachment={attachments[3]}
url={getUrl(attachments[3])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[4], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[4])}
height={99}
width={99}
darkOverlay={attachments.length > 5}
overlayText={
attachments.length > 5
? `+${attachments.length - 5}`
: undefined
}
attachment={attachments[4]}
url={getUrl(attachments[4])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
</div>
</div>
);
}
}
function getUrl(attachment: AttachmentType) {
if (attachment.screenshot) {
return attachment.screenshot.url;
}
return attachment.url;
}
export function isImage(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
isImageTypeSupported(attachments[0].contentType)
);
}
export function hasImage(attachments?: Array<AttachmentType>) {
return attachments && attachments[0] && attachments[0].url;
}
export function isVideo(attachments?: Array<AttachmentType>) {
return attachments && isVideoAttachment(attachments[0]);
}
export function isVideoAttachment(attachment?: AttachmentType) {
return (
attachment &&
attachment.contentType &&
isVideoTypeSupported(attachment.contentType)
);
}
export function hasVideoScreenshot(attachments?: Array<AttachmentType>) {
const firstAttachment = attachments ? attachments[0] : null;
return (
firstAttachment &&
firstAttachment.screenshot &&
firstAttachment.screenshot.url
);
}
type DimensionsType = {
height: number;
width: number;
};
function getImageDimensions(attachment: AttachmentType): DimensionsType {
const { height, width } = attachment;
if (!height || !width) {
return {
height: MIN_HEIGHT,
width: MIN_WIDTH,
};
}
const aspectRatio = height / width;
const targetWidth = Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH);
const candidateHeight = Math.round(targetWidth * aspectRatio);
return {
width: targetWidth,
height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT),
};
}
export function getGridDimensions(
attachments?: Array<AttachmentType>
): null | DimensionsType {
if (!attachments || !attachments.length) {
return null;
}
if (!isImage(attachments) && !isVideo(attachments)) {
return null;
}
if (attachments.length === 1) {
return getImageDimensions(attachments[0]);
}
if (attachments.length === 2) {
return {
height: 150,
width: 300,
};
}
if (attachments.length === 4) {
return {
height: 300,
width: 300,
};
}
return {
height: 200,
width: 300,
};
}
export function getAlt(attachment: AttachmentType, i18n: Localizer): string {
return isVideoAttachment(attachment)
? i18n('videoAttachmentAlt')
: i18n('imageAttachmentAlt');
}

File diff suppressed because it is too large Load Diff

@ -1,54 +1,33 @@
import React from 'react';
import classNames from 'classnames';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
import { Avatar } from '../Avatar';
import { MessageBody } from './MessageBody';
import { ExpireTimer, getIncrement } from './ExpireTimer';
import {
getGridDimensions,
hasImage,
hasVideoScreenshot,
ImageGrid,
isImage,
isVideo,
} from './ImageGrid';
import { Timestamp } from './Timestamp';
import { ContactName } from './ContactName';
import { Quote, QuotedAttachment } from './Quote';
import { Quote, QuotedAttachmentType } from './Quote';
import { EmbeddedContact } from './EmbeddedContact';
import * as MIME from '../../../ts/types/MIME';
import { AttachmentType } from './types';
import { isFileDangerous } from '../../util/isFileDangerous';
import { Contact } from '../../types/Contact';
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;
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage: boolean;
/** 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;
};
}
export interface Props {
disableMenu?: boolean;
text?: string;
@ -70,10 +49,10 @@ export interface Props {
authorPhoneNumber: string;
authorColor?: Color;
conversationType: 'group' | 'direct';
attachment?: Attachment;
attachments?: Array<AttachmentType>;
quote?: {
text: string;
attachment?: QuotedAttachment;
attachment?: QuotedAttachmentType;
isFromMe: boolean;
authorPhoneNumber: string;
authorProfileName?: string;
@ -86,7 +65,7 @@ export interface Props {
isExpired: boolean;
expirationLength?: number;
expirationTimestamp?: number;
onClickAttachment?: () => void;
onClickAttachment?: (attachment: AttachmentType) => void;
onReply?: () => void;
onRetrySend?: () => void;
onDownload?: (isDangerous: boolean) => void;
@ -100,42 +79,29 @@ interface State {
imageBroken: boolean;
}
function isImage(attachment?: Attachment) {
return (
attachment &&
attachment.contentType &&
isImageTypeSupported(attachment.contentType)
);
}
function hasImage(attachment?: Attachment) {
return attachment && attachment.url;
}
function isVideo(attachment?: Attachment) {
function isAudio(attachments?: Array<AttachmentType>) {
return (
attachment &&
attachment.contentType &&
isVideoTypeSupported(attachment.contentType)
attachments &&
attachments[0] &&
attachments[0].contentType &&
MIME.isAudio(attachments[0].contentType)
);
}
function hasVideoScreenshot(attachment?: Attachment) {
return attachment && attachment.screenshot && attachment.screenshot.url;
}
function canDisplayImage(attachments?: Array<AttachmentType>) {
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
function isAudio(attachment?: Attachment) {
return (
attachment && attachment.contentType && MIME.isAudio(attachment.contentType)
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}
function canDisplayImage(attachment?: Attachment) {
const { height, width } = attachment || { height: 0, width: 0 };
return height > 0 && height <= 4096 && width > 0 && width <= 4096;
}
function getExtension({
fileName,
contentType,
@ -159,8 +125,6 @@ function getExtension({
return null;
}
const MINIMUM_IMG_HEIGHT = 150;
const MAXIMUM_IMG_HEIGHT = 300;
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
@ -255,7 +219,7 @@ export class Message extends React.Component<Props, State> {
public renderMetadata() {
const {
attachment,
attachments,
collapseMetadata,
direction,
expirationLength,
@ -271,13 +235,13 @@ export class Message extends React.Component<Props, State> {
return null;
}
const canDisplayAttachment = canDisplayImage(attachment);
const canDisplayAttachment = canDisplayImage(attachments);
const withImageNoCaption = Boolean(
!text &&
canDisplayAttachment &&
!imageBroken &&
((isImage(attachment) && hasImage(attachment)) ||
(isVideo(attachment) && hasVideoScreenshot(attachment)))
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
);
const showError = status === 'error' && direction === 'outgoing';
@ -368,124 +332,59 @@ export class Message extends React.Component<Props, State> {
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
public renderAttachment() {
const {
i18n,
attachment,
attachments,
text,
collapseMetadata,
conversationType,
direction,
i18n,
quote,
onClickAttachment,
} = this.props;
const { imageBroken } = this.state;
if (!attachment) {
if (!attachments || !attachments[0]) {
return null;
}
const firstAttachment = attachments[0];
const withCaption = Boolean(text);
// For attachments which aren't full-frame
const withContentBelow = withCaption || !collapseMetadata;
const withContentBelow = Boolean(text);
const withContentAbove =
quote || (conversationType === 'group' && direction === 'incoming');
const displayImage = canDisplayImage(attachment);
if (isImage(attachment) && displayImage && !imageBroken && attachment.url) {
// Calculating height to prevent reflow when image loads
const imageHeight = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0);
Boolean(quote) ||
(conversationType === 'group' && direction === 'incoming');
const displayImage = canDisplayImage(attachments);
return (
<div
onClick={onClickAttachment}
role="button"
className={classNames(
'module-message__attachment-container',
withCaption
? 'module-message__attachment-container--with-content-below'
: null,
withContentAbove
? 'module-message__attachment-container--with-content-above'
: null
)}
>
<img
onError={this.handleImageErrorBound}
className="module-message__img-attachment"
height={Math.min(MAXIMUM_IMG_HEIGHT, imageHeight)}
src={attachment.url}
alt={i18n('imageAttachmentAlt')}
/>
<div
className={classNames(
'module-message__img-border-overlay',
withCaption
? 'module-message__img-border-overlay--with-content-below'
: null,
withContentAbove
? 'module-message__img-border-overlay--with-content-above'
: null
)}
/>
{!withCaption && !collapseMetadata ? (
<div className="module-message__img-overlay" />
) : null}
</div>
);
} else if (
isVideo(attachment) &&
if (
displayImage &&
!imageBroken &&
attachment.screenshot &&
attachment.screenshot.url
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
) {
const { screenshot } = attachment;
// Calculating height to prevent reflow when image loads
const imageHeight = Math.max(
MINIMUM_IMG_HEIGHT,
attachment.screenshot.height || 0
);
return (
<div
onClick={onClickAttachment}
role="button"
className={classNames(
'module-message__attachment-container',
withCaption
? 'module-message__attachment-container--with-content-below'
: null,
withContentAbove
? 'module-message__attachment-container--with-content-above'
: null,
withContentBelow
? 'module-message__attachment-container--with-content-below'
: null
)}
>
<img
<ImageGrid
attachments={attachments}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
bottomOverlay={!collapseMetadata}
i18n={i18n}
onError={this.handleImageErrorBound}
className="module-message__img-attachment"
alt={i18n('videoAttachmentAlt')}
height={Math.min(MAXIMUM_IMG_HEIGHT, imageHeight)}
src={screenshot.url}
/>
<div
className={classNames(
'module-message__img-border-overlay',
withCaption
? 'module-message__img-border-overlay--with-content-below'
: null,
withContentAbove
? 'module-message__img-border-overlay--with-content-above'
: null
)}
onClickAttachment={onClickAttachment}
/>
{!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)) {
} else if (isAudio(attachments)) {
return (
<audio
controls={true}
@ -499,11 +398,11 @@ export class Message extends React.Component<Props, State> {
: null
)}
>
<source src={attachment.url} />
<source src={firstAttachment.url} />
</audio>
);
} else {
const { fileName, fileSize, contentType } = attachment;
const { fileName, fileSize, contentType } = firstAttachment;
const extension = getExtension({ contentType, fileName });
const isDangerous = isFileDangerous(fileName || '');
@ -735,7 +634,7 @@ export class Message extends React.Component<Props, State> {
public renderMenu(isCorrectSide: boolean, triggerId: string) {
const {
attachment,
attachments,
direction,
disableMenu,
onDownload,
@ -746,23 +645,26 @@ export class Message extends React.Component<Props, State> {
return null;
}
const fileName = attachment ? attachment.fileName : null;
const fileName =
attachments && attachments[0] ? attachments[0].fileName : null;
const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
const downloadButton = attachment ? (
<div
onClick={() => {
if (onDownload) {
onDownload(isDangerous);
}
}}
role="button"
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
) : null;
const downloadButton =
!multipleAttachments && attachments && attachments[0] ? (
<div
onClick={() => {
if (onDownload) {
onDownload(isDangerous);
}
}}
role="button"
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
/>
) : null;
const replyButton = (
<div
@ -807,7 +709,7 @@ export class Message extends React.Component<Props, State> {
public renderContextMenu(triggerId: string) {
const {
attachment,
attachments,
direction,
status,
onDelete,
@ -819,12 +721,14 @@ export class Message extends React.Component<Props, State> {
} = this.props;
const showRetry = status === 'error' && direction === 'outgoing';
const fileName = attachment ? attachment.fileName : null;
const fileName =
attachments && attachments[0] ? attachments[0].fileName : null;
const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
return (
<ContextMenu id={triggerId}>
{attachment ? (
{!multipleAttachments && attachments && attachments[0] ? (
<MenuItem
attributes={{
className: 'module-message__context__download',
@ -878,13 +782,14 @@ export class Message extends React.Component<Props, State> {
public render() {
const {
attachments,
authorPhoneNumber,
authorColor,
direction,
id,
timestamp,
} = this.props;
const { expired, expiring } = this.state;
const { expired, expiring, imageBroken } = this.state;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
@ -894,6 +799,16 @@ export class Message extends React.Component<Props, State> {
return null;
}
const displayImage = canDisplayImage(attachments);
const showingImage =
displayImage &&
!imageBroken &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)));
const { width } = getGridDimensions(attachments) || { width: undefined };
return (
<div
className={classNames(
@ -901,6 +816,9 @@ export class Message extends React.Component<Props, State> {
`module-message--${direction}`,
expiring ? 'module-message--expired' : null
)}
style={{
width: showingImage ? width : undefined,
}}
>
{this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)}

@ -11,7 +11,7 @@ import { Color, Localizer } from '../../types/Util';
import { ContactName } from './ContactName';
interface Props {
attachment?: QuotedAttachment;
attachment?: QuotedAttachmentType;
authorPhoneNumber: string;
authorProfileName?: string;
authorName?: string;
@ -26,7 +26,7 @@ interface Props {
referencedMessageNotFound: boolean;
}
export interface QuotedAttachment {
export interface QuotedAttachmentType {
contentType: MIME.MIMEType;
fileName: string;
/** Not included in protobuf */

@ -0,0 +1,22 @@
### Conversation List
```jsx
<util.ConversationContext theme={util.theme}>
<TypingAnimation i18n={util.i18n} />
</util.ConversationContext>
```
### Dark background
Note: background color is 'steel'
```jsx
<div
style={{
backgroundColor: '#6b6b78',
padding: '2em',
}}
>
<TypingAnimation color="light" i18n={util.i18n} />
</div>
```

@ -0,0 +1,43 @@
import React from 'react';
import classNames from 'classnames';
import { Localizer } from '../../types/Util';
interface Props {
i18n: Localizer;
color?: string;
}
export class TypingAnimation extends React.Component<Props> {
public render() {
const { i18n, color } = this.props;
return (
<div className="module-typing-animation" title={i18n('typingAlt')}>
<div
className={classNames(
'module-typing-animation__dot',
'module-typing-animation__dot--first',
color ? `module-typing-animation__dot--${color}` : null
)}
/>
<div className="module-typing-animation__spacer" />
<div
className={classNames(
'module-typing-animation__dot',
'module-typing-animation__dot--second',
color ? `module-typing-animation__dot--${color}` : null
)}
/>
<div className="module-typing-animation__spacer" />
<div
className={classNames(
'module-typing-animation__dot',
'module-typing-animation__dot--third',
color ? `module-typing-animation__dot--${color}` : null
)}
/>
</div>
);
}
}

@ -0,0 +1,38 @@
### In message bubble
```jsx
<util.ConversationContext theme={util.theme}>
<li>
<TypingBubble conversationType="direct" i18n={util.i18n} />
</li>
<li>
<TypingBubble color="teal" conversationType="direct" i18n={util.i18n} />
</li>
</util.ConversationContext>
```
### In message bubble, group conversation
```jsx
<util.ConversationContext theme={util.theme}>
<li>
<TypingBubble color="red" conversationType="group" i18n={util.i18n} />
</li>
<li>
<TypingBubble
color="purple"
authorName="First Last"
conversationType="group"
i18n={util.i18n}
/>
</li>
<li>
<TypingBubble
avatarPath={util.gifObjectUrl}
color="blue"
conversationType="group"
i18n={util.i18n}
/>
</li>
</util.ConversationContext>
```

@ -0,0 +1,71 @@
import React from 'react';
import classNames from 'classnames';
import { TypingAnimation } from './TypingAnimation';
import { Avatar } from '../Avatar';
import { Localizer } from '../../types/Util';
interface Props {
avatarPath?: string;
color: string;
name: string;
phoneNumber: string;
profileName: string;
conversationType: string;
i18n: Localizer;
}
export class TypingBubble extends React.Component<Props> {
public renderAvatar() {
const {
avatarPath,
color,
name,
phoneNumber,
profileName,
conversationType,
i18n,
} = this.props;
if (conversationType !== 'group') {
return;
}
return (
<div className="module-message__author-avatar">
<Avatar
avatarPath={avatarPath}
color={color}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
size={36}
/>
</div>
);
}
public render() {
const { i18n, color } = this.props;
return (
<div className={classNames('module-message', 'module-message--incoming')}>
<div
className={classNames(
'module-message__container',
'module-message__container--incoming',
`module-message__container--incoming-${color}`
)}
>
<div className="module-message__typing-container">
<TypingAnimation color="light" i18n={i18n} />
</div>
{this.renderAvatar()}
</div>
</div>
);
}
}

@ -1,31 +1,33 @@
```jsx
const messages = [
const mediaItems = [
{
id: '1',
attachments: [
{
fileName: 'foo.json',
contentType: 'application/json',
size: 53313,
},
],
index: 0,
message: {
id: '1',
},
attachment: {
fileName: 'foo.json',
contentType: 'application/json',
size: 53313,
},
},
{
id: '2',
attachments: [
{
fileName: 'bar.txt',
contentType: 'text/plain',
size: 10323,
},
],
index: 1,
message: {
id: '2',
},
attachment: {
fileName: 'bar.txt',
contentType: 'text/plain',
size: 10323,
},
},
];
<AttachmentSection
header="Today"
type="documents"
messages={messages}
mediaItems={mediaItems}
i18n={util.i18n}
/>;
```

@ -1,18 +1,17 @@
import React from 'react';
import { AttachmentType } from './types/AttachmentType';
import { DocumentListItem } from './DocumentListItem';
import { ItemClickEvent } from './types/ItemClickEvent';
import { MediaGridItem } from './MediaGridItem';
import { Message } from './types/Message';
import { MediaItemType } from '../../LightboxGallery';
import { missingCaseError } from '../../../util/missingCaseError';
import { Localizer } from '../../../types/Util';
interface Props {
i18n: Localizer;
header?: string;
type: AttachmentType;
messages: Array<Message>;
type: 'media' | 'documents';
mediaItems: Array<MediaItemType>;
onItemClick?: (event: ItemClickEvent) => void;
}
@ -31,20 +30,19 @@ export class AttachmentSection extends React.Component<Props> {
}
private renderItems() {
const { i18n, messages, type } = this.props;
const { i18n, mediaItems, type } = this.props;
return messages.map((message, index, array) => {
const shouldShowSeparator = index < array.length - 1;
const { attachments } = message;
const firstAttachment = attachments[0];
return mediaItems.map((mediaItem, position, array) => {
const shouldShowSeparator = position < array.length - 1;
const { message, index, attachment } = mediaItem;
const onClick = this.createClickHandler(message);
const onClick = this.createClickHandler(mediaItem);
switch (type) {
case 'media':
return (
<MediaGridItem
key={message.id}
message={message}
key={`${message.id}-${index}`}
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
/>
@ -52,9 +50,9 @@ export class AttachmentSection extends React.Component<Props> {
case 'documents':
return (
<DocumentListItem
key={message.id}
fileName={firstAttachment.fileName}
fileSize={firstAttachment.size}
key={`${message.id}-${index}`}
fileName={attachment.fileName}
fileSize={attachment.size}
shouldShowSeparator={shouldShowSeparator}
onClick={onClick}
timestamp={message.received_at}
@ -66,12 +64,14 @@ export class AttachmentSection extends React.Component<Props> {
});
}
private createClickHandler = (message: Message) => () => {
private createClickHandler = (mediaItem: MediaItemType) => () => {
const { onItemClick, type } = this.props;
const { message, attachment } = mediaItem;
if (!onItemClick) {
return;
}
onItemClick({ type, message });
onItemClick({ type, message, attachment });
};
}

@ -26,16 +26,17 @@ const createRandomMessage = ({ startTime, timeWindow } = {}) => props => {
fileExtensions
)}`;
return {
id: _.random(now).toString(),
received_at: _.random(startTime, startTime + timeWindow),
attachments: [
{
data: null,
fileName,
size: _.random(1000, 1000 * 1000 * 50),
contentType: 'image/jpeg',
},
],
contentType: 'image/jpeg',
message: {
id: _.random(now).toString(),
received_at: _.random(startTime, startTime + timeWindow),
},
attachment: {
data: null,
fileName,
size: _.random(1000, 1000 * 1000 * 50),
contentType: 'image/jpeg',
},
thumbnailObjectUrl: `https://placekitten.com/${_.random(
50,
@ -81,17 +82,18 @@ const messages = _.sortBy(
## Media gallery with one document
```jsx
const messages = [
const mediaItems = [
{
id: '1',
thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
contentType: 'image/jpeg',
message: {
id: '1',
},
attachment: {
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
},
];
<MediaGallery i18n={util.i18n} media={messages} documents={messages} />;
<MediaGallery i18n={util.i18n} media={mediaItems} documents={mediaItems} />;
```

@ -4,29 +4,29 @@ import classNames from 'classnames';
import moment from 'moment';
import { AttachmentSection } from './AttachmentSection';
import { AttachmentType } from './types/AttachmentType';
import { EmptyState } from './EmptyState';
import { groupMessagesByDate } from './groupMessagesByDate';
import { groupMediaItemsByDate } from './groupMediaItemsByDate';
import { ItemClickEvent } from './types/ItemClickEvent';
import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError';
import { Localizer } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
interface Props {
documents: Array<Message>;
documents: Array<MediaItemType>;
i18n: Localizer;
media: Array<Message>;
media: Array<MediaItemType>;
onItemClick?: (event: ItemClickEvent) => void;
}
interface State {
selectedTab: AttachmentType;
selectedTab: 'media' | 'documents';
}
const MONTH_FORMAT = 'MMMM YYYY';
interface TabSelectEvent {
type: AttachmentType;
type: 'media' | 'documents';
}
const Tab = ({
@ -38,7 +38,7 @@ const Tab = ({
isSelected: boolean;
label: string;
onSelect?: (event: TabSelectEvent) => void;
type: AttachmentType;
type: 'media' | 'documents';
}) => {
const handleClick = onSelect
? () => {
@ -99,10 +99,10 @@ export class MediaGallery extends React.Component<Props, State> {
const { i18n, media, documents, onItemClick } = this.props;
const { selectedTab } = this.state;
const messages = selectedTab === 'media' ? media : documents;
const mediaItems = selectedTab === 'media' ? media : documents;
const type = selectedTab;
if (!messages || messages.length === 0) {
if (!mediaItems || mediaItems.length === 0) {
const label = (() => {
switch (type) {
case 'media':
@ -120,9 +120,10 @@ export class MediaGallery extends React.Component<Props, State> {
}
const now = Date.now();
const sections = groupMessagesByDate(now, messages).map(section => {
const first = section.messages[0];
const date = moment(first.received_at);
const sections = groupMediaItemsByDate(now, mediaItems).map(section => {
const first = section.mediaItems[0];
const { message } = first;
const date = moment(message.received_at);
const header =
section.type === 'yearMonth'
? date.format(MONTH_FORMAT)
@ -134,7 +135,7 @@ export class MediaGallery extends React.Component<Props, State> {
header={header}
i18n={i18n}
type={type}
messages={section.messages}
mediaItems={section.mediaItems}
onItemClick={onItemClick}
/>
);

@ -1,108 +1,94 @@
#### With image
```jsx
const message = {
id: '1',
const mediaItem = {
thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
contentType: 'image/jpeg',
attachment: {
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```
#### With video
```jsx
const message = {
id: '1',
const mediaItem = {
thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
],
contentType: 'video/mp4',
attachment: {
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```
#### Missing image
```jsx
const message = {
id: '1',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
const mediaItem = {
contentType: 'image/jpeg',
attachment: {
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```
#### Missing video
```jsx
const message = {
id: '1',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
],
const mediaItem = {
contentType: 'video/mp4',
attachment: {
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```
#### Image thumbnail failed to load
```jsx
const message = {
id: '1',
const mediaItem = {
thumbnailObjectUrl: 'nonexistent',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
contentType: 'image/jpeg',
attachment: {
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```
#### Video thumbnail failed to load
```jsx
const message = {
id: '1',
const mediaItem = {
thumbnailObjectUrl: 'nonexistent',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
],
contentType: 'video/mp4',
attachment: {
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```
#### Other contentType
```jsx
const message = {
id: '1',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'application/json',
},
],
const mediaItem = {
contentType: 'application/json',
attachment: {
fileName: 'foo.jpg',
contentType: 'application/json',
},
};
<MediaGridItem i18n={util.i18n} message={message} />;
<MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
```

@ -5,11 +5,11 @@ import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../../util/GoogleChrome';
import { Message } from './types/Message';
import { Localizer } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
interface Props {
message: Message;
mediaItem: MediaItemType;
onClick?: () => void;
i18n: Localizer;
}
@ -42,19 +42,16 @@ export class MediaGridItem extends React.Component<Props, State> {
}
public renderContent() {
const { message, i18n } = this.props;
const { mediaItem, i18n } = this.props;
const { imageBroken } = this.state;
const { attachments } = message;
const { attachment, contentType } = mediaItem;
if (!attachments || !attachments.length) {
if (!attachment) {
return null;
}
const first = attachments[0];
const { contentType } = first;
if (contentType && isImageTypeSupported(contentType)) {
if (imageBroken || !message.thumbnailObjectUrl) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
@ -69,12 +66,12 @@ export class MediaGridItem extends React.Component<Props, State> {
<img
alt={i18n('lightboxImageAlt')}
className="module-media-grid-item__image"
src={message.thumbnailObjectUrl}
src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound}
/>
);
} else if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !message.thumbnailObjectUrl) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
@ -90,7 +87,7 @@ export class MediaGridItem extends React.Component<Props, State> {
<img
alt={i18n('lightboxImageAlt')}
className="module-media-grid-item__image"
src={message.thumbnailObjectUrl}
src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound}
/>
<div className="module-media-grid-item__circle-overlay">

@ -0,0 +1,159 @@
import moment from 'moment';
import { compact, groupBy, sortBy } from 'lodash';
import { MediaItemType } from '../../LightboxGallery';
// import { missingCaseError } from '../../../util/missingCaseError';
type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth';
type YearMonthSectionType = 'yearMonth';
interface GenericSection<T> {
type: T;
mediaItems: Array<MediaItemType>;
}
type StaticSection = GenericSection<StaticSectionType>;
type YearMonthSection = GenericSection<YearMonthSectionType> & {
year: number;
month: number;
};
export type Section = StaticSection | YearMonthSection;
export const groupMediaItemsByDate = (
timestamp: number,
mediaItems: Array<MediaItemType>
): Array<Section> => {
const referenceDateTime = moment.utc(timestamp);
const sortedMediaItem = sortBy(mediaItems, mediaItem => {
const { message } = mediaItem;
return -message.received_at;
});
const messagesWithSection = sortedMediaItem.map(
withSection(referenceDateTime)
);
const groupedMediaItem = groupBy(messagesWithSection, 'type');
const yearMonthMediaItem = Object.values(
groupBy(groupedMediaItem.yearMonth, 'order')
).reverse();
return compact([
toSection(groupedMediaItem.today),
toSection(groupedMediaItem.yesterday),
toSection(groupedMediaItem.thisWeek),
toSection(groupedMediaItem.thisMonth),
...yearMonthMediaItem.map(toSection),
]);
};
const toSection = (
messagesWithSection: Array<MediaItemWithSection> | undefined
): Section | null => {
if (!messagesWithSection || messagesWithSection.length === 0) {
return null;
}
const firstMediaItemWithSection: MediaItemWithSection =
messagesWithSection[0];
if (!firstMediaItemWithSection) {
return null;
}
const mediaItems = messagesWithSection.map(
messageWithSection => messageWithSection.mediaItem
);
switch (firstMediaItemWithSection.type) {
case 'today':
case 'yesterday':
case 'thisWeek':
case 'thisMonth':
return {
type: firstMediaItemWithSection.type,
mediaItems,
};
case 'yearMonth':
return {
type: firstMediaItemWithSection.type,
year: firstMediaItemWithSection.year,
month: firstMediaItemWithSection.month,
mediaItems,
};
default:
// NOTE: Investigate why we get the following error:
// error TS2345: Argument of type 'any' is not assignable to parameter
// of type 'never'.
// return missingCaseError(firstMediaItemWithSection.type);
return null;
}
};
interface GenericMediaItemWithSection<T> {
order: number;
type: T;
mediaItem: MediaItemType;
}
type MediaItemWithStaticSection = GenericMediaItemWithSection<
StaticSectionType
>;
type MediaItemWithYearMonthSection = GenericMediaItemWithSection<
YearMonthSectionType
> & {
year: number;
month: number;
};
type MediaItemWithSection =
| MediaItemWithStaticSection
| MediaItemWithYearMonthSection;
const withSection = (referenceDateTime: moment.Moment) => (
mediaItem: MediaItemType
): MediaItemWithSection => {
const today = moment(referenceDateTime).startOf('day');
const yesterday = moment(referenceDateTime)
.subtract(1, 'day')
.startOf('day');
const thisWeek = moment(referenceDateTime).startOf('isoWeek');
const thisMonth = moment(referenceDateTime).startOf('month');
const { message } = mediaItem;
const mediaItemReceivedDate = moment.utc(message.received_at);
if (mediaItemReceivedDate.isAfter(today)) {
return {
order: 0,
type: 'today',
mediaItem,
};
}
if (mediaItemReceivedDate.isAfter(yesterday)) {
return {
order: 1,
type: 'yesterday',
mediaItem,
};
}
if (mediaItemReceivedDate.isAfter(thisWeek)) {
return {
order: 2,
type: 'thisWeek',
mediaItem,
};
}
if (mediaItemReceivedDate.isAfter(thisMonth)) {
return {
order: 3,
type: 'thisMonth',
mediaItem,
};
}
const month: number = mediaItemReceivedDate.month();
const year: number = mediaItemReceivedDate.year();
return {
order: year * 100 + month,
type: 'yearMonth',
month,
year,
mediaItem,
};
};

@ -1,150 +0,0 @@
import moment from 'moment';
import { compact, groupBy, sortBy } from 'lodash';
import { Message } from './types/Message';
// import { missingCaseError } from '../../../util/missingCaseError';
type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth';
type YearMonthSectionType = 'yearMonth';
interface GenericSection<T> {
type: T;
messages: Array<Message>;
}
type StaticSection = GenericSection<StaticSectionType>;
type YearMonthSection = GenericSection<YearMonthSectionType> & {
year: number;
month: number;
};
export type Section = StaticSection | YearMonthSection;
export const groupMessagesByDate = (
timestamp: number,
messages: Array<Message>
): Array<Section> => {
const referenceDateTime = moment.utc(timestamp);
const sortedMessages = sortBy(messages, message => -message.received_at);
const messagesWithSection = sortedMessages.map(
withSection(referenceDateTime)
);
const groupedMessages = groupBy(messagesWithSection, 'type');
const yearMonthMessages = Object.values(
groupBy(groupedMessages.yearMonth, 'order')
).reverse();
return compact([
toSection(groupedMessages.today),
toSection(groupedMessages.yesterday),
toSection(groupedMessages.thisWeek),
toSection(groupedMessages.thisMonth),
...yearMonthMessages.map(toSection),
]);
};
const toSection = (
messagesWithSection: Array<MessageWithSection> | undefined
): Section | null => {
if (!messagesWithSection || messagesWithSection.length === 0) {
return null;
}
const firstMessageWithSection: MessageWithSection = messagesWithSection[0];
if (!firstMessageWithSection) {
return null;
}
const messages = messagesWithSection.map(
messageWithSection => messageWithSection.message
);
switch (firstMessageWithSection.type) {
case 'today':
case 'yesterday':
case 'thisWeek':
case 'thisMonth':
return {
type: firstMessageWithSection.type,
messages,
};
case 'yearMonth':
return {
type: firstMessageWithSection.type,
year: firstMessageWithSection.year,
month: firstMessageWithSection.month,
messages,
};
default:
// NOTE: Investigate why we get the following error:
// error TS2345: Argument of type 'any' is not assignable to parameter
// of type 'never'.
// return missingCaseError(firstMessageWithSection.type);
return null;
}
};
interface GenericMessageWithSection<T> {
order: number;
type: T;
message: Message;
}
type MessageWithStaticSection = GenericMessageWithSection<StaticSectionType>;
type MessageWithYearMonthSection = GenericMessageWithSection<
YearMonthSectionType
> & {
year: number;
month: number;
};
type MessageWithSection =
| MessageWithStaticSection
| MessageWithYearMonthSection;
const withSection = (referenceDateTime: moment.Moment) => (
message: Message
): MessageWithSection => {
const today = moment(referenceDateTime).startOf('day');
const yesterday = moment(referenceDateTime)
.subtract(1, 'day')
.startOf('day');
const thisWeek = moment(referenceDateTime).startOf('isoWeek');
const thisMonth = moment(referenceDateTime).startOf('month');
const messageReceivedDate = moment.utc(message.received_at);
if (messageReceivedDate.isAfter(today)) {
return {
order: 0,
type: 'today',
message,
};
}
if (messageReceivedDate.isAfter(yesterday)) {
return {
order: 1,
type: 'yesterday',
message,
};
}
if (messageReceivedDate.isAfter(thisWeek)) {
return {
order: 2,
type: 'thisWeek',
message,
};
}
if (messageReceivedDate.isAfter(thisMonth)) {
return {
order: 3,
type: 'thisMonth',
message,
};
}
const month: number = messageReceivedDate.month();
const year: number = messageReceivedDate.year();
return {
order: year * 100 + month,
type: 'yearMonth',
month,
year,
message,
};
};

@ -1 +0,0 @@
export type AttachmentType = 'media' | 'documents';

@ -1,7 +1,8 @@
import { AttachmentType } from './AttachmentType';
import { AttachmentType } from '../../types';
import { Message } from './Message';
export interface ItemClickEvent {
message: Message;
type: AttachmentType;
attachment: AttachmentType;
type: 'media' | 'documents';
}

@ -4,7 +4,4 @@ export type Message = {
id: string;
attachments: Array<Attachment>;
received_at: number;
} & {
thumbnailObjectUrl?: string;
objectURL?: string;
};

@ -0,0 +1,27 @@
import { MIMEType } from '../../../ts/types/MIME';
export interface AttachmentType {
caption?: string;
contentType: MIMEType;
fileName: string;
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
url: string;
size?: number;
fileSize?: string;
width?: number;
height?: number;
screenshot?: {
height: number;
width: number;
url: string;
contentType: MIMEType;
};
thumbnail?: {
height: number;
width: number;
url: string;
contentType: MIMEType;
};
}

@ -29,10 +29,18 @@ const txtObjectUrl = makeObjectUrl(txt, 'text/plain');
import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4');
// @ts-ignore
import mp4v2 from '../../fixtures/ghost-kitty.mp4';
const mp4ObjectUrlV2 = makeObjectUrl(mp4v2, 'video/mp4');
// @ts-ignore
import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png';
// 800×1200
const pngObjectUrl = makeObjectUrl(png, 'image/png');
// @ts-ignore
import landscape from '../../fixtures/koushik-chowdavarapu-105425-unsplash.jpg';
// 800×1200
const landscapeObjectUrl = makeObjectUrl(landscape, 'image/png');
// @ts-ignore
import landscapeGreen from '../../fixtures/1000x50-green.jpeg';
const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg');
@ -64,10 +72,14 @@ export {
gifObjectUrl,
mp4,
mp4ObjectUrl,
mp4v2,
mp4ObjectUrlV2,
png,
pngObjectUrl,
txt,
txtObjectUrl,
landscape,
landscapeObjectUrl,
landscapeGreen,
landscapeGreenObjectUrl,
landscapePurple,
@ -98,3 +110,10 @@ export { theme, ios, locale, i18n };
// Telling Lodash to relinquish _ for use by underscore
// @ts-ignore
_.noConflict();
// @ts-ignore
window.log = {
info: console.log,
error: console.log,
war: console.log,
};

@ -1,87 +1,151 @@
import { assert } from 'chai';
import { shuffle } from 'lodash';
import { IMAGE_JPEG } from '../../../types/MIME';
import {
groupMessagesByDate,
groupMediaItemsByDate,
Section,
} from '../../../components/conversation/media-gallery/groupMessagesByDate';
import { Message } from '../../../components/conversation/media-gallery/types/Message';
} from '../../../components/conversation/media-gallery/groupMediaItemsByDate';
import { MediaItemType } from '../../../components/LightboxGallery';
const toMessage = (date: Date): Message => ({
id: date.toUTCString(),
received_at: date.getTime(),
attachments: [],
const toMediaItem = (date: Date): MediaItemType => ({
objectURL: date.toUTCString(),
index: 0,
message: {
id: 'id',
received_at: date.getTime(),
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
});
describe('groupMessagesByDate', () => {
it('should group messages', () => {
describe('groupMediaItemsByDate', () => {
it('should group mediaItems', () => {
const referenceTime = new Date('2018-04-12T18:00Z').getTime(); // Thu
const input: Array<Message> = shuffle([
const input: Array<MediaItemType> = shuffle([
// Today
toMessage(new Date('2018-04-12T12:00Z')), // Thu
toMessage(new Date('2018-04-12T00:01Z')), // Thu
toMediaItem(new Date('2018-04-12T12:00Z')), // Thu
toMediaItem(new Date('2018-04-12T00:01Z')), // Thu
// This week
toMessage(new Date('2018-04-11T23:59Z')), // Wed
toMessage(new Date('2018-04-09T00:01Z')), // Mon
toMediaItem(new Date('2018-04-11T23:59Z')), // Wed
toMediaItem(new Date('2018-04-09T00:01Z')), // Mon
// This month
toMessage(new Date('2018-04-08T23:59Z')), // Sun
toMessage(new Date('2018-04-01T00:01Z')),
toMediaItem(new Date('2018-04-08T23:59Z')), // Sun
toMediaItem(new Date('2018-04-01T00:01Z')),
// March 2018
toMessage(new Date('2018-03-31T23:59Z')),
toMessage(new Date('2018-03-01T14:00Z')),
toMediaItem(new Date('2018-03-31T23:59Z')),
toMediaItem(new Date('2018-03-01T14:00Z')),
// February 2011
toMessage(new Date('2011-02-28T23:59Z')),
toMessage(new Date('2011-02-01T10:00Z')),
toMediaItem(new Date('2011-02-28T23:59Z')),
toMediaItem(new Date('2011-02-01T10:00Z')),
]);
const expected: Array<Section> = [
{
type: 'today',
messages: [
mediaItems: [
{
id: 'Thu, 12 Apr 2018 12:00:00 GMT',
received_at: 1523534400000,
attachments: [],
objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1523534400000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
{
id: 'Thu, 12 Apr 2018 00:01:00 GMT',
received_at: 1523491260000,
attachments: [],
objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1523491260000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
],
},
{
type: 'yesterday',
messages: [
mediaItems: [
{
id: 'Wed, 11 Apr 2018 23:59:00 GMT',
received_at: 1523491140000,
attachments: [],
objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1523491140000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
],
},
{
type: 'thisWeek',
messages: [
mediaItems: [
{
id: 'Mon, 09 Apr 2018 00:01:00 GMT',
received_at: 1523232060000,
attachments: [],
objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1523232060000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
],
},
{
type: 'thisMonth',
messages: [
mediaItems: [
{
id: 'Sun, 08 Apr 2018 23:59:00 GMT',
received_at: 1523231940000,
attachments: [],
objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1523231940000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
{
id: 'Sun, 01 Apr 2018 00:01:00 GMT',
received_at: 1522540860000,
attachments: [],
objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1522540860000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
],
},
@ -89,16 +153,34 @@ describe('groupMessagesByDate', () => {
type: 'yearMonth',
year: 2018,
month: 2,
messages: [
mediaItems: [
{
id: 'Sat, 31 Mar 2018 23:59:00 GMT',
received_at: 1522540740000,
attachments: [],
objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1522540740000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
{
id: 'Thu, 01 Mar 2018 14:00:00 GMT',
received_at: 1519912800000,
attachments: [],
objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1519912800000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
],
},
@ -106,22 +188,40 @@ describe('groupMessagesByDate', () => {
type: 'yearMonth',
year: 2011,
month: 1,
messages: [
mediaItems: [
{
id: 'Mon, 28 Feb 2011 23:59:00 GMT',
received_at: 1298937540000,
attachments: [],
objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1298937540000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
{
id: 'Tue, 01 Feb 2011 10:00:00 GMT',
received_at: 1296554400000,
attachments: [],
objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT',
index: 0,
message: {
id: 'id',
received_at: 1296554400000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
},
],
},
];
const actual = groupMessagesByDate(referenceTime, input);
const actual = groupMediaItemsByDate(referenceTime, input);
assert.deepEqual(actual, expected);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save