Finish new Message component, integrate into application

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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