Move left pane entirely to React

This commit is contained in:
Scott Nonnenberg 2019-01-14 13:49:58 -08:00
parent bf904ddd12
commit b3ac1373fa
142 changed files with 5016 additions and 3428 deletions

View file

@ -177,6 +177,12 @@
const PASSWORD = storage.get('password');
accountManager = new textsecure.AccountManager(USERNAME, PASSWORD);
accountManager.addEventListener('registration', () => {
const user = {
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
};
Whisper.events.trigger('userChanged', user);
Whisper.Registration.markDone();
window.log.info('dispatching registration event');
Whisper.events.trigger('registration_done');
@ -535,16 +541,16 @@
window.addEventListener('focus', () => Whisper.Notifications.clear());
window.addEventListener('unload', () => Whisper.Notifications.fastClear());
Whisper.events.on('showConversation', conversation => {
Whisper.events.on('showConversation', (id, messageId) => {
if (appView) {
appView.openConversation(conversation);
appView.openConversation(id, messageId);
}
});
Whisper.Notifications.on('click', conversation => {
Whisper.Notifications.on('click', (id, messageId) => {
window.showWindow();
if (conversation) {
appView.openConversation(conversation);
if (id) {
appView.openConversation(id, messageId);
} else {
appView.openInbox({
initialLoadComplete,

View file

@ -21,25 +21,6 @@
_.debounce(this.updateUnreadCount.bind(this), 1000)
);
this.startPruning();
this.collator = new Intl.Collator();
},
comparator(m1, m2) {
const timestamp1 = m1.get('timestamp');
const timestamp2 = m2.get('timestamp');
if (timestamp1 && !timestamp2) {
return -1;
}
if (timestamp2 && !timestamp1) {
return 1;
}
if (timestamp1 && timestamp2 && timestamp1 !== timestamp2) {
return timestamp2 - timestamp1;
}
const title1 = m1.getTitle().toLowerCase();
const title2 = m2.getTitle().toLowerCase();
return this.collator.compare(title1, title2);
},
addActive(model) {
if (model.get('active_at')) {
@ -78,18 +59,6 @@
window.getInboxCollection = () => inboxCollection;
window.ConversationController = {
markAsSelected(toSelect) {
conversations.each(conversation => {
const current = conversation.isSelected || false;
const newValue = conversation.id === toSelect.id;
// eslint-disable-next-line no-param-reassign
conversation.isSelected = newValue;
if (current !== newValue) {
conversation.trigger('change');
}
});
},
get(id) {
if (!this._initialFetchComplete) {
throw new Error(

View file

@ -29,6 +29,12 @@
Message: Whisper.Message,
});
Whisper.events.trigger(
'messageExpired',
message.id,
message.conversationId
);
const conversation = message.getConversation();
if (conversation) {
conversation.trigger('expired', message);

View file

@ -1,12 +1,14 @@
/* global _: false */
/* global Backbone: false */
/* global libphonenumber: false */
/* global ConversationController: false */
/* global libsignal: false */
/* global storage: false */
/* global textsecure: false */
/* global Whisper: false */
/* global
_,
i18n,
Backbone,
libphonenumber,
ConversationController,
libsignal,
storage,
textsecure,
Whisper
*/
/* eslint-disable more/no-then */
@ -138,6 +140,13 @@
this.typingRefreshTimer = null;
this.typingPauseTimer = null;
// Keep props ready
const generateProps = () => {
this.cachedProps = this.getProps();
};
this.on('change', generateProps);
generateProps();
},
isMe() {
@ -292,40 +301,37 @@
},
format() {
return this.cachedProps;
},
getProps() {
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
const color = this.getColor();
return {
phoneNumber: format(this.id, {
ourRegionCode: regionCode,
}),
color,
avatarPath: this.getAvatarPath(),
name: this.getName(),
profileName: this.getProfileName(),
title: this.getTitle(),
};
},
getPropsForListItem() {
const typingKeys = Object.keys(this.contactTypingTimers || {});
const result = {
...this.format(),
id: this.id,
activeAt: this.get('active_at'),
avatarPath: this.getAvatarPath(),
color,
type: this.isPrivate() ? 'direct' : 'group',
isMe: this.isMe(),
conversationType: this.isPrivate() ? 'direct' : 'group',
lastUpdated: this.get('timestamp'),
unreadCount: this.get('unreadCount') || 0,
isSelected: this.isSelected,
isTyping: typingKeys.length > 0,
lastUpdated: this.get('timestamp'),
name: this.getName(),
profileName: this.getProfileName(),
timestamp: this.get('timestamp'),
title: this.getTitle(),
unreadCount: this.get('unreadCount') || 0,
phoneNumber: format(this.id, {
ourRegionCode: regionCode,
}),
lastMessage: {
status: this.get('lastMessageStatus'),
text: this.get('lastMessage'),
},
onClick: () => this.trigger('select', this),
};
return result;
@ -572,8 +578,8 @@
onMemberVerifiedChange() {
// If the verified state of a member changes, our aggregate state changes.
// We trigger both events to replicate the behavior of Backbone.Model.set()
this.trigger('change:verified');
this.trigger('change');
this.trigger('change:verified', this);
this.trigger('change', this);
},
toggleVerified() {
if (this.isVerified()) {
@ -1798,7 +1804,7 @@
if (this.isPrivate()) {
return this.get('name');
}
return this.get('name') || 'Unknown group';
return this.get('name') || i18n('unknownGroup');
},
getTitle() {
@ -1990,14 +1996,14 @@
if (!record) {
// User was not previously typing before. State change!
this.trigger('typing-update');
this.trigger('change');
this.trigger('change', this);
}
} else {
delete this.contactTypingTimers[identifier];
if (record) {
// User was previously typing, and is no longer. State change!
this.trigger('typing-update');
this.trigger('change');
this.trigger('change', this);
}
}
},
@ -2012,7 +2018,7 @@
// User was previously typing, but timed out or we received message. State change!
this.trigger('typing-update');
this.trigger('change');
this.trigger('change', this);
}
},
});
@ -2034,21 +2040,6 @@
);
this.reset([]);
},
async search(providedQuery) {
let query = providedQuery.trim().toLowerCase();
query = query.replace(/[+-.()]*/g, '');
if (query.length === 0) {
return;
}
const collection = await window.Signal.Data.searchConversations(query, {
ConversationCollection: Whisper.ConversationCollection,
});
this.reset(collection.models);
},
});
Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');

View file

@ -83,6 +83,42 @@
this.on('unload', this.unload);
this.on('expired', this.onExpired);
this.setToExpire();
// Keep props ready
const generateProps = () => {
if (this.isExpirationTimerUpdate()) {
this.propsForTimerNotification = this.getPropsForTimerNotification();
} else if (this.isKeyChange()) {
this.propsForSafetyNumberNotification = this.getPropsForSafetyNumberNotification();
} else if (this.isVerifiedChange()) {
this.propsForVerificationNotification = this.getPropsForVerificationNotification();
} else if (this.isEndSession()) {
this.propsForResetSessionNotification = this.getPropsForResetSessionNotification();
} else if (this.isGroupUpdate()) {
this.propsForGroupNotification = this.getPropsForGroupNotification();
} else {
this.propsForSearchResult = this.getPropsForSearchResult();
this.propsForMessage = this.getPropsForMessage();
}
};
this.on('change', generateProps);
const applicableConversationChanges =
'change:color change:name change:number change:profileName change:profileAvatar';
const conversation = this.getConversation();
const fromContact = this.getIncomingContact();
this.listenTo(conversation, applicableConversationChanges, generateProps);
if (fromContact) {
this.listenTo(
fromContact,
applicableConversationChanges,
generateProps
);
}
generateProps();
},
idForLogging() {
return `${this.get('source')}.${this.get('sourceDevice')} ${this.get(
@ -387,6 +423,35 @@
return 'sending';
},
getPropsForSearchResult() {
const fromNumber = this.getSource();
const from = this.findAndFormatContact(fromNumber);
if (fromNumber === this.OUR_NUMBER) {
from.isMe = true;
}
const toNumber = this.get('conversationId');
let to = this.findAndFormatContact(toNumber);
if (toNumber === this.OUR_NUMBER) {
to.isMe = true;
} else if (fromNumber === toNumber) {
to = {
isMe: true,
};
}
return {
from,
to,
isSelected: this.isSelected,
id: this.id,
conversationId: this.get('conversationId'),
receivedAt: this.get('received_at'),
snippet: this.get('snippet'),
};
},
getPropsForMessage() {
const phoneNumber = this.getSource();
const contact = this.findAndFormatContact(phoneNumber);
@ -495,7 +560,7 @@
// Would be nice to do this before render, on initial load of message
if (!window.isSignalAccountCheckComplete(firstNumber)) {
window.checkForSignalAccount(firstNumber).then(() => {
this.trigger('change');
this.trigger('change', this);
});
}

View file

@ -282,9 +282,9 @@ async function _finishJob(message, id) {
if (fromConversation && message !== fromConversation) {
fromConversation.set(message.attributes);
fromConversation.trigger('change');
fromConversation.trigger('change', fromConversation);
} else {
message.trigger('change');
message.trigger('change', message);
}
}
}

2
js/modules/data.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(query: string): Promise<Array<any>>;

View file

@ -96,7 +96,10 @@ module.exports = {
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
searchConversations,
searchMessages,
searchMessagesInConversation,
getMessageCount,
saveMessage,
@ -624,12 +627,27 @@ async function getAllGroupsInvolvingId(id, { ConversationCollection }) {
return collection;
}
async function searchConversations(query, { ConversationCollection }) {
async function searchConversations(query) {
const conversations = await channels.searchConversations(query);
return conversations;
}
const collection = new ConversationCollection();
collection.add(conversations);
return collection;
async function searchMessages(query, { limit } = {}) {
const messages = await channels.searchMessages(query, { limit });
return messages;
}
async function searchMessagesInConversation(
query,
conversationId,
{ limit } = {}
) {
const messages = await channels.searchMessagesInConversation(
query,
conversationId,
{ limit }
);
return messages;
}
// Message

View file

@ -329,8 +329,11 @@ function isChunkSneaky(chunk) {
function isLinkSneaky(link) {
const domain = getDomain(link);
// This is necesary because getDomain returns domains in punycode form
const unicodeDomain = nodeUrl.domainToUnicode(domain);
// This is necesary because getDomain returns domains in punycode form. We check whether
// it's available for the StyleGuide.
const unicodeDomain = nodeUrl.domainToUnicode
? nodeUrl.domainToUnicode(domain)
: domain;
const chunks = unicodeDomain.split('.');
for (let i = 0, max = chunks.length; i < max; i += 1) {

View file

@ -1,5 +1,6 @@
// The idea with this file is to make it webpackable for the style guide
const { bindActionCreators } = require('redux');
const Backbone = require('../../ts/backbone');
const Crypto = require('./crypto');
const Data = require('./data');
@ -29,9 +30,6 @@ const { ContactName } = require('../../ts/components/conversation/ContactName');
const {
ConversationHeader,
} = require('../../ts/components/conversation/ConversationHeader');
const {
ConversationListItem,
} = require('../../ts/components/ConversationListItem');
const {
EmbeddedContact,
} = require('../../ts/components/conversation/EmbeddedContact');
@ -44,7 +42,6 @@ const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
const { MainHeader } = require('../../ts/components/MainHeader');
const { Message } = require('../../ts/components/conversation/Message');
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
const {
@ -70,6 +67,12 @@ const {
VerificationNotification,
} = require('../../ts/components/conversation/VerificationNotification');
// State
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
const { createStore } = require('../../ts/state/createStore');
const conversationsDuck = require('../../ts/state/ducks/conversations');
const userDuck = require('../../ts/state/ducks/user');
// Migrations
const {
getPlaceholderMigrations,
@ -201,13 +204,11 @@ exports.setup = (options = {}) => {
ContactListItem,
ContactName,
ConversationHeader,
ConversationListItem,
EmbeddedContact,
Emojify,
GroupNotification,
Lightbox,
LightboxGallery,
MainHeader,
MediaGallery,
Message,
MessageBody,
@ -224,6 +225,20 @@ exports.setup = (options = {}) => {
VerificationNotification,
};
const Roots = {
createLeftPane,
};
const Ducks = {
conversations: conversationsDuck,
user: userDuck,
};
const State = {
bindActionCreators,
createStore,
Roots,
Ducks,
};
const Types = {
Attachment: AttachmentType,
Contact,
@ -262,6 +277,7 @@ exports.setup = (options = {}) => {
OS,
RefreshSenderCertificate,
Settings,
State,
Types,
Util,
Views,

View file

@ -20,7 +20,7 @@ const {
// contentType: MIMEType
// data: ArrayBuffer
// digest: ArrayBuffer
// fileName: string | null
// fileName?: string
// flags: null
// key: ArrayBuffer
// size: integer

View file

@ -39,10 +39,6 @@
this.fastUpdate = this.update;
this.update = _.debounce(this.update, 1000);
},
onClick(conversationId) {
const conversation = ConversationController.get(conversationId);
this.trigger('click', conversation);
},
update() {
if (this.lastNotification) {
this.lastNotification.close();
@ -148,7 +144,8 @@
tag: isNotificationGroupingSupported ? 'signal' : undefined,
silent: !status.shouldPlayNotificationSound,
});
notification.onclick = () => this.onClick(last.conversationId);
notification.onclick = () =>
this.trigger('click', last.conversationId, last.id);
this.lastNotification = notification;
// We continue to build up more and more messages for our notifications

View file

@ -171,10 +171,10 @@
view.onProgress(count);
}
},
openConversation(conversation) {
if (conversation) {
openConversation(id, messageId) {
if (id) {
this.openInbox().then(() => {
this.inboxView.openConversation(conversation);
this.inboxView.openConversation(id, messageId);
});
}
},

View file

@ -1,230 +0,0 @@
/* global $: false */
/* global _: false */
/* global Backbone: false */
/* global filesize: false */
/* global i18n: false */
/* global Signal: false */
/* global Whisper: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
const FileView = Whisper.View.extend({
tagName: 'div',
className: 'fileView',
templateName: 'file-view',
render_attributes() {
return this.model;
},
});
const ImageView = Backbone.View.extend({
tagName: 'img',
initialize(blobUrl) {
this.blobUrl = blobUrl;
},
events: {
load: 'update',
},
update() {
this.trigger('update');
},
render() {
this.$el.attr('src', this.blobUrl);
return this;
},
});
const MediaView = Backbone.View.extend({
initialize(dataUrl, { contentType } = {}) {
this.dataUrl = dataUrl;
this.contentType = contentType;
this.$el.attr('controls', '');
},
events: {
canplay: 'canplay',
},
canplay() {
this.trigger('update');
},
render() {
const $el = $('<source>');
$el.attr('src', this.dataUrl);
this.$el.append($el);
return this;
},
});
const AudioView = MediaView.extend({ tagName: 'audio' });
const VideoView = MediaView.extend({ tagName: 'video' });
// Blacklist common file types known to be unsupported in Chrome
const unsupportedFileTypes = ['audio/aiff', 'video/quicktime'];
Whisper.AttachmentView = Backbone.View.extend({
tagName: 'div',
className() {
if (this.isImage()) {
return 'attachment';
}
return 'attachment bubbled';
},
initialize(options) {
this.blob = new Blob([this.model.data], { type: this.model.contentType });
if (!this.model.size) {
this.model.size = this.model.data.byteLength;
}
if (options.timestamp) {
this.timestamp = options.timestamp;
}
},
events: {
click: 'onClick',
},
unload() {
this.blob = null;
if (this.lightboxView) {
this.lightboxView.remove();
}
if (this.fileView) {
this.fileView.remove();
}
if (this.view) {
this.view.remove();
}
this.remove();
},
onClick() {
if (!this.isImage()) {
this.saveFile();
return;
}
const props = {
objectURL: this.objectUrl,
contentType: this.model.contentType,
onSave: () => this.saveFile(),
// implicit: `close`
};
this.lightboxView = new Whisper.ReactWrapperView({
Component: Signal.Components.Lightbox,
props,
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
isVoiceMessage() {
return Signal.Types.Attachment.isVoiceMessage(this.model);
},
isAudio() {
const { contentType } = this.model;
// TODO: Implement and use `Signal.Util.GoogleChrome.isAudioTypeSupported`:
return Signal.Types.MIME.isAudio(contentType);
},
isVideo() {
const { contentType } = this.model;
return Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
},
isImage() {
const { contentType } = this.model;
return Signal.Util.GoogleChrome.isImageTypeSupported(contentType);
},
mediaType() {
if (this.isVoiceMessage()) {
return 'voice';
} else if (this.isAudio()) {
return 'audio';
} else if (this.isVideo()) {
return 'video';
} else if (this.isImage()) {
return 'image';
}
// NOTE: The existing code had no `return` but ESLint insists. Thought
// about throwing an error assuming this was unreachable code but it turns
// out that content type `image/tiff` falls through here:
return undefined;
},
displayName() {
if (this.isVoiceMessage()) {
return i18n('voiceMessage');
}
if (this.model.fileName) {
return this.model.fileName;
}
if (this.isAudio() || this.isVideo()) {
return i18n('mediaMessage');
}
return i18n('unnamedFile');
},
saveFile() {
Signal.Types.Attachment.save({
attachment: this.model,
document,
getAbsolutePath: Signal.Migrations.getAbsoluteAttachmentPath,
timestamp: this.timestamp,
});
},
render() {
if (!this.isImage()) {
this.renderFileView();
}
let View;
if (this.isImage()) {
View = ImageView;
} else if (this.isAudio()) {
View = AudioView;
} else if (this.isVideo()) {
View = VideoView;
}
if (!View || _.contains(unsupportedFileTypes, this.model.contentType)) {
this.update();
return this;
}
if (!this.objectUrl) {
this.objectUrl = window.URL.createObjectURL(this.blob);
}
const { blob } = this;
const { contentType } = this.model;
this.view = new View(this.objectUrl, { blob, contentType });
this.view.$el.appendTo(this.$el);
this.listenTo(this.view, 'update', this.update);
this.view.render();
if (View !== ImageView) {
this.timeout = setTimeout(this.onTimeout.bind(this), 5000);
}
return this;
},
onTimeout() {
// Image or media element failed to load. Fall back to FileView.
this.stopListening(this.view);
this.update();
},
renderFileView() {
this.fileView = new FileView({
model: {
mediaType: this.mediaType(),
fileName: this.displayName(),
fileSize: filesize(this.model.size),
altText: i18n('clickToSave'),
},
});
this.fileView.$el.appendTo(this.$el.empty());
this.fileView.render();
return this;
},
update() {
clearTimeout(this.timeout);
this.trigger('update');
},
});
})();

View file

@ -1,51 +0,0 @@
/* global Whisper, Signal, Backbone */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
// list of conversations, showing user/group and last message sent
Whisper.ConversationListItemView = Whisper.View.extend({
tagName: 'div',
className() {
return `conversation-list-item contact ${this.model.cid}`;
},
templateName: 'conversation-preview',
initialize() {
this.listenTo(this.model, 'destroy', this.remove);
},
remove() {
if (this.childView) {
this.childView.remove();
this.childView = null;
}
Backbone.View.prototype.remove.call(this);
},
render() {
if (this.childView) {
this.childView.remove();
this.childView = null;
}
const props = this.model.getPropsForListItem();
this.childView = new Whisper.ReactWrapperView({
className: 'list-item-wrapper',
Component: Signal.Components.ConversationListItem,
props,
});
const update = () =>
this.childView.update(this.model.getPropsForListItem());
this.listenTo(this.model, 'change', update);
this.$el.append(this.childView.el);
return this;
},
});
})();

View file

@ -1,68 +0,0 @@
/* global Whisper, getInboxCollection, $ */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.ConversationListView = Whisper.ListView.extend({
tagName: 'div',
itemView: Whisper.ConversationListItemView,
updateLocation(conversation) {
const $el = this.$(`.${conversation.cid}`);
if (!$el || !$el.length) {
window.log.warn(
'updateLocation: did not find element for conversation',
conversation.idForLogging()
);
return;
}
if ($el.length > 1) {
window.log.warn(
'updateLocation: found more than one element for conversation',
conversation.idForLogging()
);
return;
}
const $allConversations = this.$('.conversation-list-item');
const inboxCollection = getInboxCollection();
const index = inboxCollection.indexOf(conversation);
const elIndex = $allConversations.index($el);
if (elIndex < 0) {
window.log.warn(
'updateLocation: did not find index for conversation',
conversation.idForLogging()
);
}
if (index === elIndex) {
return;
}
if (index === 0) {
this.$el.prepend($el);
} else if (index === this.collection.length - 1) {
this.$el.append($el);
} else {
const targetConversation = inboxCollection.at(index - 1);
const target = this.$(`.${targetConversation.cid}`);
$el.insertAfter(target);
}
if ($('.selected').length) {
$('.selected')[0].scrollIntoView({
block: 'nearest',
});
}
},
removeItem(conversation) {
const $el = this.$(`.${conversation.cid}`);
if ($el && $el.length > 0) {
$el.remove();
}
},
});
})();

View file

@ -1,171 +0,0 @@
/* global ConversationController, i18n, textsecure, Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const isSearchable = conversation => conversation.isSearchable();
Whisper.NewContactView = Whisper.View.extend({
templateName: 'new-contact',
className: 'conversation-list-item contact',
events: {
click: 'validate',
},
initialize() {
this.listenTo(this.model, 'change', this.render); // auto update
},
render_attributes() {
return {
number: i18n('startConversation'),
title: this.model.getNumber(),
avatar: this.model.getAvatar(),
};
},
validate() {
if (this.model.isValid()) {
this.$el.addClass('valid');
} else {
this.$el.removeClass('valid');
}
},
});
Whisper.ConversationSearchView = Whisper.View.extend({
className: 'conversation-search',
initialize(options) {
this.$input = options.input;
this.$new_contact = this.$('.new-contact');
this.typeahead = new Whisper.ConversationCollection();
this.collection = new Whisper.ConversationCollection([], {
comparator(m) {
return m.getTitle().toLowerCase();
},
});
this.listenTo(this.collection, 'select', conversation => {
this.resetTypeahead();
this.trigger('open', conversation);
});
// View to display the matched contacts from typeahead
this.typeahead_view = new Whisper.ConversationListView({
collection: this.collection,
});
this.$el.append(this.typeahead_view.el);
this.initNewContact();
this.pending = Promise.resolve();
},
events: {
'click .new-contact': 'createConversation',
},
filterContacts() {
const query = this.$input.val().trim();
if (query.length) {
if (this.maybeNumber(query)) {
this.new_contact_view.model.set('id', query);
this.new_contact_view.render().$el.show();
this.new_contact_view.validate();
this.hideHints();
} else {
this.new_contact_view.$el.hide();
}
// NOTE: Temporarily allow `then` until we convert the entire file
// to `async` / `await`:
/* eslint-disable more/no-then */
this.pending = this.pending.then(() =>
this.typeahead.search(query).then(() => {
let results = this.typeahead.filter(isSearchable);
const noteToSelf = i18n('noteToSelf');
if (noteToSelf.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
const ourNumber = textsecure.storage.user.getNumber();
const conversation = ConversationController.get(ourNumber);
if (conversation) {
// ensure that we don't have duplicates in our results
results = results.filter(item => item.id !== ourNumber);
results.unshift(conversation);
}
}
this.typeahead_view.collection.reset(results);
})
);
/* eslint-enable more/no-then */
this.trigger('show');
} else {
this.resetTypeahead();
}
},
initNewContact() {
if (this.new_contact_view) {
this.new_contact_view.undelegateEvents();
this.new_contact_view.$el.hide();
}
const model = new Whisper.Conversation({ type: 'private' });
this.new_contact_view = new Whisper.NewContactView({
el: this.$new_contact,
model,
}).render();
},
async createConversation() {
const isValidNumber = this.new_contact_view.model.isValid();
if (!isValidNumber) {
this.new_contact_view.$('.number').text(i18n('invalidNumberError'));
this.$input.focus();
return;
}
const newConversationId = this.new_contact_view.model.id;
const conversation = await ConversationController.getOrCreateAndWait(
newConversationId,
'private'
);
this.trigger('open', conversation);
this.initNewContact();
this.resetTypeahead();
},
reset() {
this.delegateEvents();
this.typeahead_view.delegateEvents();
this.new_contact_view.delegateEvents();
this.resetTypeahead();
},
resetTypeahead() {
this.hideHints();
this.new_contact_view.$el.hide();
this.$input.val('').focus();
this.typeahead_view.collection.reset([]);
this.trigger('hide');
},
showHints() {
if (!this.hintView) {
this.hintView = new Whisper.HintView({
className: 'contact placeholder',
content: i18n('newPhoneNumber'),
}).render();
this.hintView.$el.insertAfter(this.$input);
}
this.hintView.$el.show();
},
hideHints() {
if (this.hintView) {
this.hintView.remove();
this.hintView = null;
}
},
maybeNumber(number) {
return number.replace(/[\s-.()]*/g, '').match(/^\+?[0-9]*$/);
},
});
})();

View file

@ -1377,11 +1377,7 @@
},
async openConversation(number) {
const conversation = await window.ConversationController.getOrCreateAndWait(
number,
'private'
);
window.Whisper.events.trigger('showConversation', conversation);
window.Whisper.events.trigger('showConversation', number);
},
listenBack(view) {

View file

@ -1,18 +0,0 @@
/* global Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.HintView = Whisper.View.extend({
templateName: 'hint',
initialize(options) {
this.content = options.content;
},
render_attributes() {
return { content: this.content };
},
});
})();

View file

@ -1,10 +1,12 @@
/* global ConversationController: false */
/* global extension: false */
/* global getInboxCollection: false */
/* global i18n: false */
/* global Whisper: false */
/* global textsecure: false */
/* global Signal: false */
/* global
ConversationController,
extension,
getInboxCollection,
i18n,
Whisper,
textsecure,
Signal
*/
// eslint-disable-next-line func-names
(function() {
@ -38,38 +40,6 @@
},
});
Whisper.FontSizeView = Whisper.View.extend({
defaultSize: 14,
maxSize: 30,
minSize: 14,
initialize() {
this.currentSize = this.defaultSize;
this.render();
},
events: { keydown: 'zoomText' },
zoomText(e) {
if (!e.ctrlKey) {
return;
}
const keyCode = e.which || e.keyCode;
const maxSize = 22; // if bigger text goes outside send-message textarea
const minSize = 14;
if (keyCode === 189 || keyCode === 109) {
if (this.currentSize > minSize) {
this.currentSize -= 1;
}
} else if (keyCode === 187 || keyCode === 107) {
if (this.currentSize < maxSize) {
this.currentSize += 1;
}
}
this.render();
},
render() {
this.$el.css('font-size', `${this.currentSize}px`);
},
});
Whisper.AppLoadingScreen = Whisper.View.extend({
templateName: 'app-loading-screen',
className: 'app-loading-screen',
@ -92,20 +62,6 @@
this.render();
this.$el.attr('tabindex', '1');
// eslint-disable-next-line no-new
new Whisper.FontSizeView({ el: this.$el });
const ourNumber = textsecure.storage.user.getNumber();
const me = ConversationController.getOrCreate(ourNumber, 'private');
this.mainHeaderView = new Whisper.ReactWrapperView({
className: 'main-header-wrapper',
Component: Signal.Components.MainHeader,
props: me.format(),
});
const update = () => this.mainHeaderView.update(me.format());
this.listenTo(me, 'change', update);
this.$('.main-header-placeholder').append(this.mainHeaderView.el);
this.conversation_stack = new Whisper.ConversationStack({
el: this.$('.conversation-stack'),
model: { window: options.window },
@ -125,40 +81,6 @@
this.networkStatusView.render();
}
});
this.listenTo(inboxCollection, 'select', this.openConversation);
this.inboxListView = new Whisper.ConversationListView({
el: this.$('.inbox'),
collection: inboxCollection,
}).render();
this.inboxListView.listenTo(
inboxCollection,
'add change:timestamp change:name change:number',
this.inboxListView.updateLocation
);
this.inboxListView.listenTo(
inboxCollection,
'remove',
this.inboxListView.removeItem
);
this.searchView = new Whisper.ConversationSearchView({
el: this.$('.search-results'),
input: this.$('input.search'),
});
this.searchView.$el.hide();
this.listenTo(this.searchView, 'hide', function toggleVisibility() {
this.searchView.$el.hide();
this.inboxListView.$el.show();
});
this.listenTo(this.searchView, 'show', function toggleVisibility() {
this.searchView.$el.show();
this.inboxListView.$el.hide();
});
this.listenTo(this.searchView, 'open', this.openConversation);
this.networkStatusView = new Whisper.NetworkStatusView();
this.$el
@ -170,18 +92,78 @@
banner.$el.prependTo(this.$el);
this.$el.addClass('expired');
}
this.setupLeftPane();
},
render_attributes: {
welcomeToSignal: i18n('welcomeToSignal'),
selectAContact: i18n('selectAContact'),
searchForPeopleOrGroups: i18n('searchForPeopleOrGroups'),
settings: i18n('settings'),
},
events: {
click: 'onClick',
'click #header': 'focusHeader',
'click .conversation': 'focusConversation',
'input input.search': 'filterContacts',
},
setupLeftPane() {
// Here we set up a full redux store with initial state for our LeftPane Root
const inboxCollection = getInboxCollection();
const conversations = inboxCollection.map(
conversation => conversation.cachedProps
);
const initialState = {
conversations: {
conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
},
user: {
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
i18n: window.i18n,
},
};
this.store = Signal.State.createStore(initialState);
window.inboxStore = this.store;
this.leftPaneView = new Whisper.ReactWrapperView({
JSX: Signal.State.Roots.createLeftPane(this.store),
className: 'left-pane-wrapper',
});
// Enables our redux store to be updated by backbone events in the outside world
const {
conversationAdded,
conversationChanged,
conversationRemoved,
removeAllConversations,
messageExpired,
openConversationExternal,
} = Signal.State.bindActionCreators(
Signal.State.Ducks.conversations.actions,
this.store.dispatch
);
const { userChanged } = Signal.State.bindActionCreators(
Signal.State.Ducks.user.actions,
this.store.dispatch
);
this.openConversationAction = openConversationExternal;
this.listenTo(inboxCollection, 'remove', conversation => {
const { id } = conversation || {};
conversationRemoved(id);
});
this.listenTo(inboxCollection, 'add', conversation => {
const { id, cachedProps } = conversation || {};
conversationAdded(id, cachedProps);
});
this.listenTo(inboxCollection, 'change', conversation => {
const { id, cachedProps } = conversation || {};
conversationChanged(id, cachedProps);
});
this.listenTo(inboxCollection, 'reset', removeAllConversations);
Whisper.events.on('messageExpired', messageExpired);
Whisper.events.on('userChanged', userChanged);
// Finally, add it to the DOM
this.$('.left-pane-placeholder').append(this.leftPaneView.el);
},
startConnectionListener() {
this.interval = setInterval(() => {
@ -237,30 +219,18 @@
reloadBackgroundPage() {
window.location.reload();
},
filterContacts(e) {
this.searchView.filterContacts(e);
const input = this.$('input.search');
if (input.val().length > 0) {
input.addClass('active');
const textDir = window.getComputedStyle(input[0]).direction;
if (textDir === 'ltr') {
input.removeClass('rtl').addClass('ltr');
} else if (textDir === 'rtl') {
input.removeClass('ltr').addClass('rtl');
}
} else {
input.removeClass('active');
}
},
openConversation(conversation) {
this.searchView.hideHints();
if (conversation) {
ConversationController.markAsSelected(conversation);
this.conversation_stack.open(
ConversationController.get(conversation.id)
);
this.focusConversation();
async openConversation(id, messageId) {
const conversation = await window.ConversationController.getOrCreateAndWait(
id,
'private'
);
if (this.openConversationAction) {
this.openConversationAction(id, messageId);
}
this.conversation_stack.open(conversation);
this.focusConversation();
},
closeRecording(e) {
if (e && this.$(e.target).closest('.capture-audio').length > 0) {

View file

@ -44,36 +44,36 @@
getRenderInfo() {
const { Components } = window.Signal;
if (this.model.isExpirationTimerUpdate()) {
if (this.model.propsForTimerNotification) {
return {
Component: Components.TimerNotification,
props: this.model.getPropsForTimerNotification(),
props: this.model.propsForTimerNotification,
};
} else if (this.model.isKeyChange()) {
} else if (this.model.propsForSafetyNumberNotification) {
return {
Component: Components.SafetyNumberNotification,
props: this.model.getPropsForSafetyNumberNotification(),
props: this.model.propsForSafetyNumberNotification,
};
} else if (this.model.isVerifiedChange()) {
} else if (this.model.propsForVerificationNotification) {
return {
Component: Components.VerificationNotification,
props: this.model.getPropsForVerificationNotification(),
props: this.model.propsForVerificationNotification,
};
} else if (this.model.isEndSession()) {
} else if (this.model.propsForResetSessionNotification) {
return {
Component: Components.ResetSessionNotification,
props: this.model.getPropsForResetSessionNotification(),
props: this.model.propsForResetSessionNotification,
};
} else if (this.model.isGroupUpdate()) {
} else if (this.model.propsForGroupNotification) {
return {
Component: Components.GroupNotification,
props: this.model.getPropsForGroupNotification(),
props: this.model.propsForGroupNotification,
};
}
return {
Component: Components.Message,
props: this.model.getPropsForMessage(),
props: this.model.propsForMessage,
};
},
render() {

View file

@ -14,6 +14,7 @@
initialize(options) {
const {
Component,
JSX,
props,
onClose,
tagName,
@ -28,6 +29,7 @@
this.tagName = tagName;
this.className = className;
this.JSX = JSX;
this.Component = Component;
this.onClose = onClose;
this.onInitialRender = onInitialRender;
@ -38,7 +40,9 @@
},
update(props) {
const updatedProps = this.augmentProps(props);
const reactElement = React.createElement(this.Component, updatedProps);
const reactElement = this.JSX
? this.JSX
: React.createElement(this.Component, updatedProps);
ReactDOM.render(reactElement, this.el, () => {
if (this.hasRendered) {
return;

View file

@ -1,129 +0,0 @@
/* global moment: false */
/* global Whisper: false */
/* global extension: false */
/* global i18n: false */
/* global _: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
function extendedRelativeTime(number, string) {
return moment.duration(-1 * number, string).humanize(string !== 's');
}
const extendedFormats = {
y: 'lll',
M: `${i18n('timestampFormat_M') || 'MMM D'} LT`,
d: 'ddd LT',
};
function shortRelativeTime(number, string) {
return moment.duration(number, string).humanize();
}
const shortFormats = {
y: 'll',
M: i18n('timestampFormat_M') || 'MMM D',
d: 'ddd',
};
function getRelativeTimeSpanString(rawTimestamp, options = {}) {
_.defaults(options, { extended: false });
const relativeTime = options.extended
? extendedRelativeTime
: shortRelativeTime;
const formats = options.extended ? extendedFormats : shortFormats;
// Convert to moment timestamp if it isn't already
const timestamp = moment(rawTimestamp);
const now = moment();
const timediff = moment.duration(now - timestamp);
if (timediff.years() > 0) {
return timestamp.format(formats.y);
} else if (timediff.months() > 0 || timediff.days() > 6) {
return timestamp.format(formats.M);
} else if (timediff.days() > 0) {
return timestamp.format(formats.d);
} else if (timediff.hours() >= 1) {
return relativeTime(timediff.hours(), 'h');
} else if (timediff.minutes() >= 1) {
// Note that humanize seems to jump to '1 hour' as soon as we cross 45 minutes
return relativeTime(timediff.minutes(), 'm');
}
return relativeTime(timediff.seconds(), 's');
}
Whisper.TimestampView = Whisper.View.extend({
initialize() {
extension.windows.onClosed(this.clearTimeout.bind(this));
},
update() {
this.clearTimeout();
const millisNow = Date.now();
let millis = this.$el.data('timestamp');
if (millis === '') {
return;
}
if (millis >= millisNow) {
millis = millisNow;
}
const result = this.getRelativeTimeSpanString(millis);
this.delay = this.getDelay(millis);
this.$el.text(result);
const timestamp = moment(millis);
this.$el.attr('title', timestamp.format('llll'));
if (this.delay) {
if (this.delay < 0) {
this.delay = 1000;
}
this.timeout = setTimeout(this.update.bind(this), this.delay);
}
},
clearTimeout() {
clearTimeout(this.timeout);
},
getRelativeTimeSpanString(timestamp) {
return getRelativeTimeSpanString(timestamp);
},
getDelay(rawTimestamp) {
// Convert to moment timestamp if it isn't already
const timestamp = moment(rawTimestamp);
const now = moment();
const timediff = moment.duration(now - timestamp);
if (timediff.years() > 0) {
return null;
} else if (timediff.months() > 0 || timediff.days() > 6) {
return null;
} else if (timediff.days() > 0) {
return moment(timestamp)
.add(timediff.days() + 1, 'd')
.diff(now);
} else if (timediff.hours() >= 1) {
return moment(timestamp)
.add(timediff.hours() + 1, 'h')
.diff(now);
} else if (timediff.minutes() >= 1) {
return moment(timestamp)
.add(timediff.minutes() + 1, 'm')
.diff(now);
}
return moment(timestamp)
.add(1, 'm')
.diff(now);
},
});
Whisper.ExtendedTimestampView = Whisper.TimestampView.extend({
getRelativeTimeSpanString(timestamp) {
return getRelativeTimeSpanString(timestamp, { extended: true });
},
});
})();