From b3ac1373fa64117fe2a9ccfddf3712f1826c06d9 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 14 Jan 2019 13:49:58 -0800 Subject: [PATCH] Move left pane entirely to React --- _locales/en/messages.json | 35 +- app/sql.js | 6 +- background.html | 153 +-- images/search.svg | 5 +- images/x-16.svg | 2 +- images/x.svg | 2 +- js/background.js | 16 +- js/conversation_controller.js | 31 - js/expiring_messages.js | 6 + js/models/conversations.js | 95 +- js/models/messages.js | 67 +- js/modules/attachment_downloads.js | 4 +- js/modules/data.d.ts | 2 + js/modules/data.js | 26 +- js/modules/link_previews.js | 7 +- js/modules/signal.js | 28 +- js/modules/types/attachment.js | 2 +- js/notifications.js | 7 +- js/views/app_view.js | 6 +- js/views/attachment_view.js | 230 ---- js/views/conversation_list_item_view.js | 51 - js/views/conversation_list_view.js | 68 -- js/views/conversation_search_view.js | 171 --- js/views/conversation_view.js | 6 +- js/views/hint_view.js | 18 - js/views/inbox_view.js | 200 ++-- js/views/message_view.js | 22 +- js/views/react_wrapper_view.js | 6 +- js/views/timestamp_view.js | 129 -- libtextsecure/account_manager.js | 7 +- package.json | 28 +- settings_preload.js | 8 +- styleguide.config.js | 8 +- stylesheets/_emoji.scss | 1 - stylesheets/_global.scss | 189 +-- stylesheets/_index.scss | 7 + stylesheets/_modules.scss | 267 ++++- stylesheets/_recorder.scss | 1 - stylesheets/_theme_dark.scss | 123 +- test/_test.js | 7 +- test/conversation_controller_test.js | 48 - test/index.html | 433 ++++--- test/models/conversations_test.js | 48 - test/modules/types/attachment_test.js | 9 - test/views/attachment_view_test.js | 59 - test/views/conversation_search_view_test.js | 91 -- test/views/inbox_view_test.js | 5 + test/views/threads_test.js | 21 - test/views/timestamp_view_test.js | 139 --- ts/backbone/views/Lightbox.ts | 4 +- ts/components/Avatar.tsx | 7 +- ts/components/CaptionEditor.tsx | 49 +- ts/components/ContactListItem.tsx | 6 +- ts/components/ConversationListItem.md | 93 +- ts/components/ConversationListItem.tsx | 37 +- ts/components/Intl.tsx | 10 +- ts/components/LeftPane.md | 168 +++ ts/components/LeftPane.tsx | 71 ++ ts/components/Lightbox.tsx | 66 +- ts/components/LightboxGallery.tsx | 19 +- ts/components/MainHeader.md | 58 +- ts/components/MainHeader.tsx | 139 ++- ts/components/MessageBodyHighlight.md | 41 + ts/components/MessageBodyHighlight.tsx | 111 ++ ts/components/MessageSearchResult.md | 191 +++ ts/components/MessageSearchResult.tsx | 166 +++ ts/components/SearchResults.md | 259 ++++ ts/components/SearchResults.tsx | 118 ++ ts/components/StartNewConversation.md | 23 + ts/components/StartNewConversation.tsx | 43 + ts/components/conversation/AddNewLines.tsx | 4 +- ts/components/conversation/AttachmentList.tsx | 44 +- ts/components/conversation/ContactDetail.tsx | 23 +- ts/components/conversation/ContactName.tsx | 4 +- .../conversation/ConversationHeader.tsx | 34 +- .../conversation/EmbeddedContact.tsx | 98 +- ts/components/conversation/Emojify.tsx | 8 +- ts/components/conversation/ExpireTimer.tsx | 24 +- .../conversation/GroupNotification.tsx | 28 +- ts/components/conversation/Image.tsx | 9 +- ts/components/conversation/ImageGrid.tsx | 203 +--- ts/components/conversation/Linkify.tsx | 4 +- ts/components/conversation/Message.tsx | 102 +- ts/components/conversation/MessageBody.tsx | 10 +- ts/components/conversation/MessageDetail.tsx | 4 +- ts/components/conversation/Quote.tsx | 18 +- .../conversation/ResetSessionNotification.tsx | 4 +- .../conversation/SafetyNumberNotification.tsx | 9 +- .../conversation/StagedGenericAttachment.tsx | 10 +- .../conversation/StagedLinkPreview.tsx | 7 +- .../conversation/TimerNotification.tsx | 11 +- ts/components/conversation/Timestamp.tsx | 8 +- .../conversation/TypingAnimation.tsx | 4 +- ts/components/conversation/TypingBubble.tsx | 4 +- .../conversation/VerificationNotification.tsx | 4 +- ts/components/conversation/_contactUtil.tsx | 93 ++ .../media-gallery/AttachmentSection.tsx | 6 +- .../media-gallery/DocumentListItem.tsx | 2 +- .../media-gallery/MediaGallery.tsx | 6 +- .../media-gallery/MediaGridItem.tsx | 6 +- .../media-gallery/groupMediaItemsByDate.ts | 8 +- .../media-gallery/types/ItemClickEvent.ts | 2 +- ts/components/conversation/types.ts | 28 - ts/shims/Whisper.ts | 4 + ts/shims/events.ts | 4 + ts/state/actions.ts | 15 + ts/state/createStore.ts | 33 + ts/state/ducks/conversations.ts | 273 +++++ ts/state/ducks/noop.ts | 4 + ts/state/ducks/search.ts | 291 +++++ ts/state/ducks/user.ts | 67 ++ ts/state/reducer.ts | 25 + ts/state/roots/createLeftPane.tsx | 16 + ts/state/selectors/conversations.ts | 124 ++ ts/state/selectors/search.ts | 93 ++ ts/state/selectors/user.ts | 23 + ts/state/smart/LeftPane.tsx | 32 + ts/state/smart/MainHeader.tsx | 23 + ts/state/smart/MessageSearchResult.tsx | 23 + ts/styleguide/LeftPaneContext.tsx | 5 +- ts/styleguide/StyleGuideUtil.ts | 7 +- ts/test/state/selectors/conversations_test.ts | 96 ++ ts/test/types/Attachment_test.ts | 1 - ts/test/types/Conversation_test.ts | 16 +- ts/types/Attachment.ts | 251 +++- ts/types/{Contact.ts => Contact.tsx} | 10 +- ts/types/Conversation.ts | 32 +- ts/types/PhoneNumber.ts | 18 + ts/types/Util.ts | 6 +- ts/util/cleanSearchTerm.ts | 24 + ts/util/formatRelativeTime.ts | 8 +- ts/util/getInitials.ts | 6 +- ts/util/index.ts | 2 + ts/util/lint/exceptions.json | 1044 ++++------------- ts/util/lint/linter.ts | 5 + ts/util/lint/rules.json | 1 + ts/util/lint/types.ts | 4 +- ts/util/makeLookup.ts | 12 + ts/util/timer.ts | 23 + tsconfig.json | 2 +- tslint.json | 12 +- yarn.lock | 401 ++++++- 142 files changed, 5016 insertions(+), 3428 deletions(-) create mode 100644 js/modules/data.d.ts delete mode 100644 js/views/attachment_view.js delete mode 100644 js/views/conversation_list_item_view.js delete mode 100644 js/views/conversation_list_view.js delete mode 100644 js/views/conversation_search_view.js delete mode 100644 js/views/hint_view.js delete mode 100644 js/views/timestamp_view.js delete mode 100644 test/conversation_controller_test.js delete mode 100644 test/views/attachment_view_test.js delete mode 100644 test/views/conversation_search_view_test.js delete mode 100644 test/views/threads_test.js delete mode 100644 test/views/timestamp_view_test.js create mode 100644 ts/components/LeftPane.md create mode 100644 ts/components/LeftPane.tsx create mode 100644 ts/components/MessageBodyHighlight.md create mode 100644 ts/components/MessageBodyHighlight.tsx create mode 100644 ts/components/MessageSearchResult.md create mode 100644 ts/components/MessageSearchResult.tsx create mode 100644 ts/components/SearchResults.md create mode 100644 ts/components/SearchResults.tsx create mode 100644 ts/components/StartNewConversation.md create mode 100644 ts/components/StartNewConversation.tsx create mode 100644 ts/components/conversation/_contactUtil.tsx delete mode 100644 ts/components/conversation/types.ts create mode 100644 ts/shims/Whisper.ts create mode 100644 ts/shims/events.ts create mode 100644 ts/state/actions.ts create mode 100644 ts/state/createStore.ts create mode 100644 ts/state/ducks/conversations.ts create mode 100644 ts/state/ducks/noop.ts create mode 100644 ts/state/ducks/search.ts create mode 100644 ts/state/ducks/user.ts create mode 100644 ts/state/reducer.ts create mode 100644 ts/state/roots/createLeftPane.tsx create mode 100644 ts/state/selectors/conversations.ts create mode 100644 ts/state/selectors/search.ts create mode 100644 ts/state/selectors/user.ts create mode 100644 ts/state/smart/LeftPane.tsx create mode 100644 ts/state/smart/MainHeader.tsx create mode 100644 ts/state/smart/MessageSearchResult.tsx create mode 100644 ts/test/state/selectors/conversations_test.ts rename ts/types/{Contact.ts => Contact.tsx} (90%) create mode 100644 ts/util/cleanSearchTerm.ts create mode 100644 ts/util/makeLookup.ts create mode 100644 ts/util/timer.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index eab144415155..c70e06a07dd8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -9,6 +9,11 @@ "description": "Shown in the top-level error popup, allowing user to copy the error text and close the app" }, + "unknownGroup": { + "message": "Unknown group", + "description": + "Shown as the name of a group if we don't have any information about it" + }, "databaseError": { "message": "Database Error", "description": "Shown in a popup if the database cannot start up properly" @@ -710,10 +715,32 @@ "message": "Signal Desktop", "description": "Tooltip for the tray icon" }, - "searchForPeopleOrGroups": { - "message": "Enter name or number", + "search": { + "message": "Search", "description": "Placeholder text in the search input" }, + "noSearchResults": { + "message": "No results for \"$searchTerm$\"", + "description": "Shown in the search left pane when no results were found", + "placeholders": { + "searchTerm": { + "content": "$1", + "example": "dog" + } + } + }, + "conversationsHeader": { + "message": "Conversations", + "description": "Shown to separate the types of search results" + }, + "contactsHeader": { + "message": "Contacts", + "description": "Shown to separate the types of search results" + }, + "messagesHeader": { + "message": "Messages", + "description": "Shown to separate the types of search results" + }, "welcomeToSignal": { "message": "Welcome to Signal" }, @@ -883,7 +910,7 @@ "description": "Label for the sender of a message" }, "to": { - "message": "To", + "message": "to", "description": "Label for the receiver of a message" }, "sent": { @@ -1567,7 +1594,7 @@ "description": "Label text for menu bar visibility setting" }, "startConversation": { - "message": "Start conversation…", + "message": "Start new conversation…", "description": "Label underneath number a user enters that is not an existing contact" }, diff --git a/app/sql.js b/app/sql.js index 79daedb950a9..ff004629dc66 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1146,7 +1146,7 @@ async function getAllGroupsInvolvingId(id) { return map(rows, row => jsonToObject(row.json)); } -async function searchConversations(query) { +async function searchConversations(query, { limit } = {}) { const rows = await db.all( `SELECT json FROM conversations WHERE ( @@ -1154,11 +1154,13 @@ async function searchConversations(query) { name LIKE $name OR profileName LIKE $profileName ) - ORDER BY id ASC;`, + ORDER BY id ASC + LIMIT $limit`, { $id: `%${query}%`, $name: `%${query}%`, $profileName: `%${query}%`, + $limit: limit || 50, } ); diff --git a/background.html b/background.html index 9d780a0f8e03..189dceccc3d8 100644 --- a/background.html +++ b/background.html @@ -38,6 +38,7 @@
{{ message }}
+ + + + + + + - + + + + - - + - - - - - - + + + + + - - - - - - + - - - - - - diff --git a/images/search.svg b/images/search.svg index 747e21d26f31..1704b4082bb5 100644 --- a/images/search.svg +++ b/images/search.svg @@ -1,4 +1 @@ - - - - +search-16 \ No newline at end of file diff --git a/images/x-16.svg b/images/x-16.svg index 6554bf274f22..00c43c414604 100644 --- a/images/x-16.svg +++ b/images/x-16.svg @@ -1 +1 @@ -x-16 \ No newline at end of file +x-16 diff --git a/images/x.svg b/images/x.svg index 40b5e39d6fa4..9166cbe37f97 100644 --- a/images/x.svg +++ b/images/x.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/js/background.js b/js/background.js index aea05034b3f0..0783879fd175 100644 --- a/js/background.js +++ b/js/background.js @@ -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, diff --git a/js/conversation_controller.js b/js/conversation_controller.js index e736a2cb50e6..e3eb312b8d6a 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -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( diff --git a/js/expiring_messages.js b/js/expiring_messages.js index dc3f43f4d971..f187a92aa4d1 100644 --- a/js/expiring_messages.js +++ b/js/expiring_messages.js @@ -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); diff --git a/js/models/conversations.js b/js/models/conversations.js index 0d675f65d28e..fcca6456479b 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -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(' '); diff --git a/js/models/messages.js b/js/models/messages.js index 2e19f96c2a78..cdd662681beb 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -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); }); } diff --git a/js/modules/attachment_downloads.js b/js/modules/attachment_downloads.js index 51ba6aeea79b..9a74b8aed210 100644 --- a/js/modules/attachment_downloads.js +++ b/js/modules/attachment_downloads.js @@ -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); } } } diff --git a/js/modules/data.d.ts b/js/modules/data.d.ts new file mode 100644 index 000000000000..1cec9dcb9754 --- /dev/null +++ b/js/modules/data.d.ts @@ -0,0 +1,2 @@ +export function searchMessages(query: string): Promise>; +export function searchConversations(query: string): Promise>; diff --git a/js/modules/data.js b/js/modules/data.js index 8eb925045878..973042e1c201 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -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 diff --git a/js/modules/link_previews.js b/js/modules/link_previews.js index 42b66f3aa7de..828d46ac50c6 100644 --- a/js/modules/link_previews.js +++ b/js/modules/link_previews.js @@ -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) { diff --git a/js/modules/signal.js b/js/modules/signal.js index e51ecd72112a..7d81b01a9b8b 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -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, diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index d134af13ae21..48af3a17b510 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -20,7 +20,7 @@ const { // contentType: MIMEType // data: ArrayBuffer // digest: ArrayBuffer -// fileName: string | null +// fileName?: string // flags: null // key: ArrayBuffer // size: integer diff --git a/js/notifications.js b/js/notifications.js index 1fb70b76a0b3..6aaff5257f0e 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -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 diff --git a/js/views/app_view.js b/js/views/app_view.js index 1db6314e3c09..3fd906e98534 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -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); }); } }, diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js deleted file mode 100644 index 15654e82bdc3..000000000000 --- a/js/views/attachment_view.js +++ /dev/null @@ -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 = $(''); - $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'); - }, - }); -})(); diff --git a/js/views/conversation_list_item_view.js b/js/views/conversation_list_item_view.js deleted file mode 100644 index 7b7618b3dd50..000000000000 --- a/js/views/conversation_list_item_view.js +++ /dev/null @@ -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; - }, - }); -})(); diff --git a/js/views/conversation_list_view.js b/js/views/conversation_list_view.js deleted file mode 100644 index cbf3554a5a11..000000000000 --- a/js/views/conversation_list_view.js +++ /dev/null @@ -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(); - } - }, - }); -})(); diff --git a/js/views/conversation_search_view.js b/js/views/conversation_search_view.js deleted file mode 100644 index d1aa848e77bd..000000000000 --- a/js/views/conversation_search_view.js +++ /dev/null @@ -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]*$/); - }, - }); -})(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 2f601c2cc330..963b2105ced1 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -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) { diff --git a/js/views/hint_view.js b/js/views/hint_view.js deleted file mode 100644 index 8d1a54813f72..000000000000 --- a/js/views/hint_view.js +++ /dev/null @@ -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 }; - }, - }); -})(); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 5bccdd94a630..9891be3ef728 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -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) { diff --git a/js/views/message_view.js b/js/views/message_view.js index b260b9ba1793..f2c718839f49 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -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() { diff --git a/js/views/react_wrapper_view.js b/js/views/react_wrapper_view.js index a5a5ccaa8021..a256fff1888c 100644 --- a/js/views/react_wrapper_view.js +++ b/js/views/react_wrapper_view.js @@ -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; diff --git a/js/views/timestamp_view.js b/js/views/timestamp_view.js deleted file mode 100644 index c443f5390cce..000000000000 --- a/js/views/timestamp_view.js +++ /dev/null @@ -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 }); - }, - }); -})(); diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index 102fae03a298..1d932bc10eb4 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -489,10 +489,9 @@ response.deviceId || 1, deviceName ); - await textsecure.storage.put( - 'regionCode', - libphonenumber.util.getRegionCodeForNumber(number) - ); + + const regionCode = libphonenumber.util.getRegionCodeForNumber(number); + await textsecure.storage.put('regionCode', regionCode); }, async clearSessionsAndPreKeys() { const store = textsecure.storage.protocol; diff --git a/package.json b/package.json index 0e7a5f724196..9766c6d88faf 100644 --- a/package.json +++ b/package.json @@ -77,12 +77,18 @@ "node-sass": "4.9.3", "os-locale": "2.1.0", "pify": "3.0.0", - "protobufjs": "~6.8.6", + "protobufjs": "6.8.6", "proxy-agent": "3.0.3", - "react": "16.2.0", + "react": "16.8.3", "react-contextmenu": "2.9.2", - "react-dom": "16.2.0", + "react-dom": "16.8.3", + "react-redux": "6.0.1", + "react-virtualized": "9.21.0", "read-last-lines": "1.3.0", + "redux": "4.0.1", + "redux-logger": "3.0.6", + "redux-promise-middleware": "6.1.0", + "reselect": "4.0.0", "rimraf": "2.6.2", "semver": "5.4.1", "spellchecker": "3.4.4", @@ -99,13 +105,15 @@ "@types/classnames": "2.2.3", "@types/filesize": "3.6.0", "@types/google-libphonenumber": "7.4.14", - "@types/jquery": "3.3.1", + "@types/jquery": "3.3.29", "@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/react": "16.8.5", + "@types/react-dom": "16.8.2", + "@types/react-redux": "7.0.1", + "@types/redux-logger": "3.0.7", "@types/semver": "5.5.0", "@types/sinon": "4.3.1", "arraybuffer-loader": "1.0.3", @@ -141,10 +149,10 @@ "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", + "tslint": "5.13.0", + "tslint-microsoft-contrib": "6.0.0", + "tslint-react": "3.6.0", + "typescript": "3.3.3333", "webpack": "4.4.1" }, "engines": { diff --git a/settings_preload.js b/settings_preload.js index ada9db6568b3..858a88c29be7 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -11,6 +11,10 @@ const localeMessages = ipcRenderer.sendSync('locale-data'); window.theme = config.theme; window.i18n = i18n.setup(locale, localeMessages); +window.getEnvironment = () => config.environment; +window.getVersion = () => config.version; +window.getAppInstance = () => config.appInstance; + // So far we're only using this for Signal.Types const Signal = require('./js/modules/signal'); @@ -20,10 +24,6 @@ window.Signal = Signal.setup({ getRegionCode: () => null, }); -window.getEnvironment = () => config.environment; -window.getVersion = () => config.version; -window.getAppInstance = () => config.appInstance; - window.closeSettings = () => ipcRenderer.send('close-settings'); window.getDeviceName = makeGetter('device-name'); diff --git a/styleguide.config.js b/styleguide.config.js index 07157d868649..c58cf7d82a17 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -9,22 +9,22 @@ module.exports = { { name: 'Components', description: '', - components: 'ts/components/*.tsx', + components: 'ts/components/[^_]*.tsx', }, { name: 'Conversation', description: 'Everything necessary to render a conversation', - components: 'ts/components/conversation/*.tsx', + components: 'ts/components/conversation/[^_]*.tsx', }, { name: 'Media Gallery', description: 'Display media and documents in a conversation', - components: 'ts/components/conversation/media-gallery/*.tsx', + components: 'ts/components/conversation/media-gallery/[^_]*.tsx', }, { name: 'Utility', description: 'Utility components used across the application', - components: 'ts/components/utility/*.tsx', + components: 'ts/components/utility/[^_]*.tsx', }, { name: 'Test', diff --git a/stylesheets/_emoji.scss b/stylesheets/_emoji.scss index 2ade390c66a1..77b636ddba8c 100644 --- a/stylesheets/_emoji.scss +++ b/stylesheets/_emoji.scss @@ -91,7 +91,6 @@ button.emoji { margin-top: 3px; &:before { - margin-top: 4px; content: ''; display: inline-block; width: $button-height; diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 65378097a297..5b1d19ee156e 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -143,63 +143,6 @@ a { } } -.dropoff { - outline: solid 1px $blue; -} - -$avatar-size: 48px; - -.avatar { - display: inline-block; - height: $avatar-size; - width: $avatar-size; - border-radius: 50%; - background-size: cover; - vertical-align: middle; - text-align: center; - line-height: $avatar-size; - overflow-x: hidden; - text-overflow: ellipsis; - color: $color-white; - font-size: 18px; - background-color: $grey; -} - -.group-info-input { - background: $color-white; - - .group-avatar { - display: inline-block; - padding: 2px 0px 0px 2px; - } - - .file-input .thumbnail, - .thumbnail .avatar, - img { - height: 54px; - width: 54px; - border-radius: (54px / 2); - } - - .thumbnail:after { - content: ''; - position: absolute; - height: 0; - width: 0; - bottom: 0; - right: 0; - border-bottom: 10px solid $grey; - border-left: 10px solid transparent; - } - - input.name { - padding: 0.5em; - border: solid 1px #ccc; - border-width: 0 0 1px 0; - width: calc(100% - 84px); - } -} - .group-member-list, .new-group-update { .summary { @@ -257,123 +200,6 @@ $unread-badge-size: 21px; } } -$new-contact-left-margin: 16px; -.new-contact .avatar { - margin-left: $new-contact-left-margin; -} - -// Still used for the contact search view -.contact-details { - $left-margin: 12px; - - vertical-align: middle; - display: inline-block; - margin: 0 0 0 $left-margin; - width: calc( - 100% - #{$avatar-size} - #{$new-contact-left-margin} - #{$left-margin} - #{( - 4/14 - ) + em} - ); - text-align: left; - - p { - overflow-x: hidden; - overflow-y: hidden; - height: 1.2em; - text-overflow: ellipsis; - } - - .name { - display: block; - margin: 0; - font-size: 1em; - text-overflow: ellipsis; - overflow-x: hidden; - text-align: left; - } - - .number { - color: $color-light-60; - font-size: $font-size-small; - } - - &.clickable { - cursor: pointer; - } - - .verified-icon { - @include color-svg('../images/verified-check.svg', $grey); - display: inline-block; - width: 1.25em; - height: 1.25em; - vertical-align: text-bottom; - } - - .body-wrapper { - overflow-x: hidden; - overflow-y: hidden; - text-overflow: ellipsis; - } -} - -.recipients-input { - position: relative; - - .recipients-container { - background-color: white; - padding: 2px; - border-bottom: 1px solid #f2f2f2; - line-height: 24px; - } - - .recipient { - display: inline-block; - margin: 0 2px 2px 0; - padding: 0 5px; - border-radius: 10px; - background-color: $blue; - color: white; - - &.error { - background-color: #f00; - } - - .remove { - margin-left: 5px; - padding: 0 2px; - } - } - - .results { - position: absolute; - z-index: 10; - margin: 0 0 0 20px; - width: calc(100% - 30px); - max-width: 300px; - max-height: 55px * 3; - overflow-y: auto; - box-shadow: 0px 0px 1px rgba(#aaa, 0.8); - - .contact { - cursor: pointer; - } - } -} - -.attachment-preview { - display: inline-block; - position: relative; - img { - max-width: 100%; - } -} -.new-conversation .recipients-input .recipients::before { - content: 'To: '; -} -.new-group-update .recipients-input .recipients::before { - content: 'Add: '; -} - $loading-height: 16px; .loading { @@ -437,10 +263,6 @@ $loading-height: 16px; } } -.inbox { - position: relative; -} - @keyframes loading { 50% { transform: scale(1); @@ -805,13 +627,6 @@ $loading-height: 16px; outline: none; } -.text-security .inbox { - .name, - .body, - .last-message, - .sender, - .conversation-title, - .number { - -webkit-text-security: square; - } +.inbox { + position: relative; } diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index f0eb7fc21409..6c11abee155a 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -74,6 +74,13 @@ } } +.left-pane-placeholder { + height: 100%; +} +.left-pane-wrapper { + height: 100%; +} + .conversation-stack { .conversation { display: none; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 0f0a7f7de574..3e097a78c076 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2096,19 +2096,64 @@ .module-main-header { height: $header-height; - margin-left: 16px; + width: 300px; + + padding-left: 16px; display: flex; flex-direction: row; align-items: center; + + border-bottom: 1px solid $color-gray-15; } -.module-main-header__app-name { - font-size: 16px; - line-height: 24px; - font-weight: 300; - margin-left: 32px; +.module-main-header__search { + margin-left: 12px; + position: relative; +} + +.module-main-header__search__input { + height: 28px; + width: 228px; + + border-radius: 14px; + border: solid 1px $color-gray-15; + + padding-left: 30px; + padding-right: 30px; + color: $color-gray-90; + font-size: 14px; + + &::placeholder { + color: $color-gray-45; + } + + &:focus { + border: solid 1px blue; + outline: none; + } +} + +.module-main-header__search__icon { + position: absolute; + left: 8px; + top: 6px; + height: 16px; + width: 16px; + + cursor: text; + @include color-svg('../images/search.svg', $color-gray-60); +} + +.module-main-header__search__cancel-icon { + position: absolute; + right: 8px; + top: 7px; + height: 14px; + width: 14px; + cursor: pointer; + @include color-svg('../images/x-16.svg', $color-gray-60); } // Module: Image @@ -2771,6 +2816,216 @@ background-color: $color-white; } +// Module: Highlighted Message Body + +.module-message-body__highlight { + font-weight: bold; +} + +// Module: Search Results + +.module-search-results { + overflow-y: scroll; + max-height: 100%; +} + +.module-search-results__conversations-header { + height: 36px; + line-height: 36px; + + margin-left: 16px; + + font-size: 14px; + font-weight: 300; + letter-spacing: 0; +} + +.module-search-results__no-results { + margin-top: 27px; + width: 100%; + text-align: center; +} + +.module-search-results__contacts-header { + height: 36px; + line-height: 36px; + + margin-left: 16px; + + font-size: 14px; + font-weight: 300; + letter-spacing: 0; +} + +.module-search-results__messages-header { + height: 36px; + line-height: 36px; + + margin-left: 16px; + + font-size: 14px; + font-weight: 300; + letter-spacing: 0; +} + +// Module: Message Search Result + +.module-message-search-result { + padding: 8px; + padding-left: 16px; + padding-right: 16px; + min-height: 64px; + max-width: 300px; + + display: flex; + flex-direction: row; + align-items: flex-start; + + cursor: pointer; + &:hover { + background-color: $color-gray-05; + } +} + +.module-message-search-result--is-selected { + background-color: $color-gray-05; +} + +.module-message-search-result__text { + flex-grow: 1; + margin-left: 12px; + // parent - 48px (for avatar) - 16px (our right margin) + max-width: calc(100% - 64px); + + display: inline-flex; + flex-direction: column; + align-items: stretch; +} + +.module-message-search-result__header { + display: flex; + flex-direction: row; + align-items: center; +} + +.module-message-search-result__header__from { + flex-grow: 1; + flex-shrink: 1; + font-size: 14px; + line-height: 18px; + + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + color: $color-gray-90; +} + +.module-message-search-result__header__timestamp { + flex-shrink: 0; + margin-left: 6px; + + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + text-transform: uppercase; + + color: $color-gray-60; +} + +.module-message-search-result__header__name { + font-weight: 300; +} +.module-mesages-search-result__header__group { + font-weight: 300; +} + +.module-message-search-result__body { + margin-top: 1px; + flex-grow: 1; + flex-shrink: 1; + + font-size: 13px; + + color: $color-gray-60; + + max-height: 3.6em; + + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + + // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use + // ... as the truncation indicator. That's not a solution that works well for + // all languages. More resources: + // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ + // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 +} + +// Module: Left Pane + +.module-left-pane { + background-color: $color-gray-02; + border-right: 1px solid $color-gray-15; + + display: inline-flex; + flex-direction: column; + + width: 300px; + height: 100%; +} + +.module-left-pane__header { + flex-shrink: 0; + flex-grow: 0; +} + +.module-left-pane__list { + flex-grow: 1; + flex-shrink: 1; + + overflow-y: scroll; +} + +// Module: Start New Conversation + +.module-start-new-conversation { + display: flex; + flex-direction: row; + align-items: center; + + padding-top: 8px; + padding-bottom: 8px; + padding-left: 16px; + + cursor: pointer; + + &:hover { + background-color: $color-gray-05; + } +} + +.module-start-new-conversation__content { + margin-left: 12px; +} + +.module-start-new-conversation__number { + font-weight: 300; +} + +.module-start-new-conversation__text { + margin-top: 3px; + font-style: italic; + color: $color-gray-60; + font-size: 13px; +} + // Third-party module: react-contextmenu .react-contextmenu { diff --git a/stylesheets/_recorder.scss b/stylesheets/_recorder.scss index a6b2370cdb91..b76a1985e5a2 100644 --- a/stylesheets/_recorder.scss +++ b/stylesheets/_recorder.scss @@ -17,7 +17,6 @@ } &:before { - margin-top: 4px; content: ''; display: inline-block; height: 24px; diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index f31721f1dbe3..007eb6e660ae 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -166,6 +166,7 @@ body.dark-theme { button.emoji { &:before { + margin-top: 4px; @include color-svg('../images/smile.svg', $color-dark-30); } } @@ -1320,8 +1321,32 @@ body.dark-theme { // Module: Main Header - .module-main-header__app-name { - color: $color-dark-05; + .module-main-header { + border-bottom: 1px solid $color-gray-75; + } + + .module-main-header__search__input { + background-color: $color-gray-95; + border-radius: 14px; + border: solid 1px $color-gray-75; + color: $color-gray-05; + + &::placeholder { + color: $color-gray-45; + } + + &:focus { + border: solid 1px blue; + outline: none; + } + } + + .module-main-header__search__icon { + @include color-svg('../images/search.svg', $color-gray-25); + } + + .module-main-header__search__cancel-icon { + @include color-svg('../images/x.svg', $color-gray-25); } // Module: Image @@ -1450,6 +1475,100 @@ body.dark-theme { background-color: $color-gray-05; } + // Module: Caption Editor + + .module-caption-editor { + background-color: $color-black; + } + + .module-caption-editor__close-button { + @include color-svg('../images/x.svg', $color-white); + } + + .module-caption-editor__media-container { + background-color: $color-black; + } + + .module-caption-editor__caption-input { + border: 1px solid $color-white; + color: $color-white; + background-color: $color-black; + + &::placeholder { + color: $color-white-07; + } + &:focus { + border: 1px solid $color-signal-blue; + outline: none; + } + } + + .module-caption-editor__save-button { + background-color: $color-signal-blue; + color: $color-white; + } + + // Module: Highlighted Message Body + + // Module: Search Results + + .module-search-results__conversations-header { + color: $color-gray-05; + } + .module-search-results__contacts-header { + color: $color-gray-05; + } + .module-search-results__messages-header { + color: $color-gray-05; + } + + // Module: Message Search Result + + .module-message-search-result { + &:hover { + background-color: $color-dark-70; + } + } + + .module-message-search-result--is-selected { + background-color: $color-dark-70; + } + + .module-message-search-result__header__from { + color: $color-gray-05; + } + + .module-message-search-result__header__timestamp { + color: $color-gray-25; + } + + .module-message-search-result__body { + color: $color-gray-05; + } + + // Module: Left Pane + + .module-left-pane { + background-color: $color-dark-85; + border-right: 1px solid $color-gray-75; + } + + // Module: Start New Conversation + + .module-start-new-conversation { + &:hover { + background-color: $color-dark-70; + } + } + + .module-start-new-conversation__number { + color: $color-gray-05; + } + + .module-start-new-conversation__text { + color: $color-gray-45; + } + // Third-party module: react-contextmenu .react-contextmenu { diff --git a/test/_test.js b/test/_test.js index fd9f8fcc21ff..6e1e03ae2351 100644 --- a/test/_test.js +++ b/test/_test.js @@ -1,4 +1,4 @@ -/* global chai, Whisper */ +/* global chai, Whisper, _, Backbone */ mocha.setup('bdd'); window.assert = chai.assert; @@ -74,8 +74,13 @@ function deleteIndexedDB() { before(async () => { await deleteIndexedDB(); await window.Signal.Data.removeAll(); + await window.storage.fetch(); }); window.clearDatabase = async () => { await window.Signal.Data.removeAll(); + await window.storage.fetch(); }; + +window.Whisper = window.Whisper || {}; +window.Whisper.events = _.clone(Backbone.Events); diff --git a/test/conversation_controller_test.js b/test/conversation_controller_test.js deleted file mode 100644 index b8c01d99cf89..000000000000 --- a/test/conversation_controller_test.js +++ /dev/null @@ -1,48 +0,0 @@ -/* global Whisper */ - -'use strict'; - -describe('ConversationController', () => { - it('sorts conversations based on timestamp then by intl-friendly title', () => { - const collection = window.getInboxCollection(); - collection.reset([]); - - collection.add( - new Whisper.Conversation({ - name: 'No timestamp', - }) - ); - collection.add( - new Whisper.Conversation({ - name: 'B', - timestamp: 20, - }) - ); - collection.add( - new Whisper.Conversation({ - name: 'C', - timestamp: 20, - }) - ); - collection.add( - new Whisper.Conversation({ - name: 'Á', - timestamp: 20, - }) - ); - collection.add( - new Whisper.Conversation({ - name: 'First!', - timestamp: 30, - }) - ); - - assert.strictEqual(collection.at('0').get('name'), 'First!'); - assert.strictEqual(collection.at('1').get('name'), 'Á'); - assert.strictEqual(collection.at('2').get('name'), 'B'); - assert.strictEqual(collection.at('3').get('name'), 'C'); - assert.strictEqual(collection.at('4').get('name'), 'No timestamp'); - - collection.reset([]); - }); -}); diff --git a/test/index.html b/test/index.html index 8d64375df246..98c04b2b5432 100644 --- a/test/index.html +++ b/test/index.html @@ -16,19 +16,22 @@
+ + + + + + + + - + + + + + - - + - - - - - - + + + + + - - - - - - - + - + + + + @@ -356,13 +492,9 @@ - - - - @@ -370,7 +502,6 @@ - @@ -384,11 +515,9 @@ - - diff --git a/test/models/conversations_test.js b/test/models/conversations_test.js index 0292ef24dd4a..121f12ee5351 100644 --- a/test/models/conversations_test.js +++ b/test/models/conversations_test.js @@ -163,51 +163,3 @@ describe('Conversation', () => { }); }); }); - -describe('Conversation search', () => { - let convo; - - beforeEach(async () => { - convo = new Whisper.ConversationCollection().add({ - id: '+14155555555', - type: 'private', - name: 'John Doe', - }); - await window.Signal.Data.saveConversation(convo.attributes, { - Conversation: Whisper.Conversation, - }); - }); - - afterEach(clearDatabase); - - async function testSearch(queries) { - await Promise.all( - queries.map(async query => { - const collection = new Whisper.ConversationCollection(); - await collection.search(query); - - assert.isDefined(collection.get(convo.id), `no result for "${query}"`); - }) - ); - } - it('matches by partial phone number', () => { - return testSearch([ - '1', - '4', - '+1', - '415', - '4155', - '4155555555', - '14155555555', - '+14155555555', - ]); - }); - it('matches by name', () => { - return testSearch(['John', 'Doe', 'john', 'doe', 'John Doe', 'john doe']); - }); - it('does not match +', async () => { - const collection = new Whisper.ConversationCollection(); - await collection.search('+'); - assert.isUndefined(collection.get(convo.id), 'got result for "+"'); - }); -}); diff --git a/test/modules/types/attachment_test.js b/test/modules/types/attachment_test.js index 7c54a33e1196..f55aacef01d7 100644 --- a/test/modules/types/attachment_test.js +++ b/test/modules/types/attachment_test.js @@ -12,13 +12,11 @@ describe('Attachment', () => { it('should sanitize left-to-right order override character', async () => { const input = { contentType: 'image/jpeg', - data: null, fileName: 'test\u202Dfig.exe', size: 1111, }; const expected = { contentType: 'image/jpeg', - data: null, fileName: 'test\uFFFDfig.exe', size: 1111, }; @@ -30,13 +28,11 @@ describe('Attachment', () => { it('should sanitize right-to-left order override character', async () => { const input = { contentType: 'image/jpeg', - data: null, fileName: 'test\u202Efig.exe', size: 1111, }; const expected = { contentType: 'image/jpeg', - data: null, fileName: 'test\uFFFDfig.exe', size: 1111, }; @@ -48,13 +44,11 @@ describe('Attachment', () => { it('should sanitize multiple override characters', async () => { const input = { contentType: 'image/jpeg', - data: null, fileName: 'test\u202e\u202dlol\u202efig.exe', size: 1111, }; const expected = { contentType: 'image/jpeg', - data: null, fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe', size: 1111, }; @@ -72,7 +66,6 @@ describe('Attachment', () => { fileName => { const input = { contentType: 'image/jpeg', - data: null, fileName, size: 1111, }; @@ -131,7 +124,6 @@ describe('Attachment', () => { it('should remove existing schema version', () => { const input = { contentType: 'image/jpeg', - data: null, fileName: 'foo.jpg', size: 1111, schemaVersion: 1, @@ -139,7 +131,6 @@ describe('Attachment', () => { const expected = { contentType: 'image/jpeg', - data: null, fileName: 'foo.jpg', size: 1111, }; diff --git a/test/views/attachment_view_test.js b/test/views/attachment_view_test.js deleted file mode 100644 index 99d8d1f3e1c8..000000000000 --- a/test/views/attachment_view_test.js +++ /dev/null @@ -1,59 +0,0 @@ -/* global assert, storage, Whisper */ - -'use strict'; - -describe('AttachmentView', () => { - let convo; - - before(async () => { - await clearDatabase(); - - convo = new Whisper.Conversation({ id: 'foo' }); - convo.messageCollection.add({ - conversationId: convo.id, - body: 'hello world', - type: 'outgoing', - source: '+14158675309', - received_at: Date.now(), - }); - - await storage.put('number_id', '+18088888888.1'); - }); - - describe('with arbitrary files', () => { - it('should render a file view', () => { - const attachment = { - contentType: 'unused', - size: 1232, - }; - const view = new Whisper.AttachmentView({ model: attachment }).render(); - assert.match(view.el.innerHTML, /fileView/); - }); - it('should display the filename if present', () => { - const attachment = { - fileName: 'foo.txt', - contentType: 'unused', - size: 1232, - }; - const view = new Whisper.AttachmentView({ model: attachment }).render(); - assert.match(view.el.innerHTML, /foo.txt/); - }); - it('should render a file size', () => { - const attachment = { - size: 1232, - contentType: 'unused', - }; - const view = new Whisper.AttachmentView({ model: attachment }).render(); - assert.match(view.el.innerHTML, /1.2 KB/); - }); - }); - it('should render an image for images', () => { - const now = new Date().getTime(); - const attachment = { contentType: 'image/png', data: 'grumpy cat' }; - const view = new Whisper.AttachmentView({ - model: attachment, - timestamp: now, - }).render(); - assert.equal(view.el.firstChild.tagName, 'IMG'); - }); -}); diff --git a/test/views/conversation_search_view_test.js b/test/views/conversation_search_view_test.js deleted file mode 100644 index d1bd9dc380af..000000000000 --- a/test/views/conversation_search_view_test.js +++ /dev/null @@ -1,91 +0,0 @@ -/* global $, Whisper */ - -describe('ConversationSearchView', () => { - it('should match partial numbers', () => { - const $el = $('
'); - const view = new Whisper.ConversationSearchView({ - el: $el, - input: $(''), - }).render(); - const maybeNumbers = [ - '+1 415', - '+1415', - '+1415', - '415', - '(415)', - ' (415', - '(415) 123 4567', - '+1 (415) 123 4567', - ' +1 (415) 123 4567', - '1 (415) 123 4567', - '1 415-123-4567', - '415-123-4567', - ]; - maybeNumbers.forEach(n => { - assert.ok(view.maybeNumber(n), n); - }); - }); - describe('Searching for left groups', () => { - let convo; - - before(() => { - convo = new Whisper.ConversationCollection().add({ - id: '1-search-view', - name: 'i left this group', - members: [], - type: 'group', - left: true, - }); - - return window.Signal.Data.saveConversation(convo.attributes, { - Conversation: Whisper.Conversation, - }); - }); - describe('with no messages', () => { - let input; - let view; - - before(done => { - input = $(''); - view = new Whisper.ConversationSearchView({ input }).render(); - view.$input.val('left'); - view.filterContacts(); - view.typeahead_view.collection.on('reset', () => { - done(); - }); - }); - it('should not surface left groups with no messages', () => { - assert.isUndefined( - view.typeahead_view.collection.get(convo.id), - 'got left group' - ); - }); - }); - describe('with messages', () => { - let input; - let view; - before(async () => { - input = $(''); - view = new Whisper.ConversationSearchView({ input }).render(); - convo.set({ id: '2-search-view', left: false }); - - await window.Signal.Data.saveConversation(convo.attributes, { - Conversation: Whisper.Conversation, - }); - - view.$input.val('left'); - view.filterContacts(); - - return new Promise(resolve => { - view.typeahead_view.collection.on('reset', resolve); - }); - }); - it('should surface left groups with messages', () => { - assert.isDefined( - view.typeahead_view.collection.get(convo.id), - 'got left group' - ); - }); - }); - }); -}); diff --git a/test/views/inbox_view_test.js b/test/views/inbox_view_test.js index abc076139cb7..9f336368691a 100644 --- a/test/views/inbox_view_test.js +++ b/test/views/inbox_view_test.js @@ -7,6 +7,11 @@ describe('InboxView', () => { before(async () => { ConversationController.reset(); await ConversationController.load(); + await textsecure.storage.user.setNumberAndDeviceId( + '18005554444', + 1, + 'Home Office' + ); await ConversationController.getOrCreateAndWait( textsecure.storage.user.getNumber(), 'private' diff --git a/test/views/threads_test.js b/test/views/threads_test.js deleted file mode 100644 index 3999d23e9efe..000000000000 --- a/test/views/threads_test.js +++ /dev/null @@ -1,21 +0,0 @@ -/* global Whisper */ - -describe('Threads', () => { - it('should be ordered newest to oldest', () => { - // Timestamps - const today = new Date(); - const tomorrow = new Date(); - tomorrow.setDate(today.getDate() + 1); - - // Add threads - Whisper.Threads.add({ timestamp: today }); - Whisper.Threads.add({ timestamp: tomorrow }); - - const { models } = Whisper.Threads; - const firstTimestamp = models[0].get('timestamp').getTime(); - const secondTimestamp = models[1].get('timestamp').getTime(); - - // Compare timestamps - assert(firstTimestamp > secondTimestamp); - }); -}); diff --git a/test/views/timestamp_view_test.js b/test/views/timestamp_view_test.js deleted file mode 100644 index 2e22d734fa50..000000000000 --- a/test/views/timestamp_view_test.js +++ /dev/null @@ -1,139 +0,0 @@ -/* global moment, Whisper */ - -'use strict'; - -describe('TimestampView', () => { - it('formats long-ago timestamps correctly', () => { - const timestamp = Date.now(); - const briefView = new Whisper.TimestampView({ brief: true }).render(); - const extendedView = new Whisper.ExtendedTimestampView().render(); - - // Helper functions to check absolute and relative timestamps - - // Helper to check an absolute TS for an exact match - const check = (view, ts, expected) => { - const result = view.getRelativeTimeSpanString(ts); - assert.strictEqual(result, expected); - }; - - // Helper to check relative times for an exact match against both views - const checkDiff = (secAgo, expectedBrief, expectedExtended) => { - check(briefView, timestamp - secAgo * 1000, expectedBrief); - check(extendedView, timestamp - secAgo * 1000, expectedExtended); - }; - - // Helper to check an absolute TS for an exact match against both views - const checkAbs = (ts, expectedBrief, expectedExtended) => { - if (!expectedExtended) { - // eslint-disable-next-line no-param-reassign - expectedExtended = expectedBrief; - } - check(briefView, ts, expectedBrief); - check(extendedView, ts, expectedExtended); - }; - - // Helper to check an absolute TS for a match at the beginning against - const checkStartsWith = (view, ts, expected) => { - const result = view.getRelativeTimeSpanString(ts); - const regexp = new RegExp(`^${expected}`); - assert.match(result, regexp); - }; - - // check integer timestamp, JS Date object and moment object - checkAbs(timestamp, 'now', 'now'); - checkAbs(new Date(), 'now', 'now'); - checkAbs(moment(), 'now', 'now'); - - // check recent timestamps - checkDiff(30, 'now', 'now'); // 30 seconds - checkDiff(40 * 60, '40 minutes', '40 minutes ago'); - checkDiff(60 * 60, '1 hour', '1 hour ago'); - checkDiff(125 * 60, '2 hours', '2 hours ago'); - - // set to third of month to avoid problems on the 29th/30th/31st - const lastMonth = moment() - .subtract(1, 'month') - .date(3); - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - check(briefView, lastMonth, `${months[lastMonth.month()]} 3`); - checkStartsWith(extendedView, lastMonth, `${months[lastMonth.month()]} 3`); - - // subtract 26 hours to be safe in case of DST stuff - const yesterday = new Date(timestamp - 26 * 60 * 60 * 1000); - const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - check(briefView, yesterday, daysOfWeek[yesterday.getDay()]); - checkStartsWith(extendedView, yesterday, daysOfWeek[yesterday.getDay()]); - - // Check something long ago - // months are zero-indexed in JS for some reason - check(briefView, new Date(2012, 4, 5, 17, 30, 0), 'May 5, 2012'); - checkStartsWith( - extendedView, - new Date(2012, 4, 5, 17, 30, 0), - 'May 5, 2012' - ); - }); - - describe('updates within a minute reasonable intervals', () => { - let view; - beforeEach(() => { - view = new Whisper.TimestampView(); - }); - afterEach(() => { - clearTimeout(view.timeout); - }); - - it('updates timestamps this minute within a minute', () => { - const now = Date.now(); - view.$el.attr('data-timestamp', now - 1000); - view.update(); - assert.isAbove(view.delay, 0); // non zero - assert.isBelow(view.delay, 60 * 1000); // < minute - }); - - it('updates timestamps from this hour within a minute', () => { - const now = Date.now(); - view.$el.attr('data-timestamp', now - 1000 - 1000 * 60 * 5); // 5 minutes and 1 sec ago - view.update(); - assert.isAbove(view.delay, 0); // non zero - assert.isBelow(view.delay, 60 * 1000); // minute - }); - - it('updates timestamps from today within an hour', () => { - const now = Date.now(); - view.$el.attr('data-timestamp', now - 1000 - 1000 * 60 * 60 * 5); // 5 hours and 1 sec ago - view.update(); - assert.isAbove(view.delay, 60 * 1000); // minute - assert.isBelow(view.delay, 60 * 60 * 1000); // hour - }); - - it('updates timestamps from this week within a day', () => { - const now = Date.now(); - view.$el.attr('data-timestamp', now - 1000 - 6 * 24 * 60 * 60 * 1000); // 6 days and 1 sec ago - view.update(); - assert.isAbove(view.delay, 60 * 60 * 1000); // hour - assert.isBelow(view.delay, 36 * 60 * 60 * 1000); // day and a half - }); - - it('does not updates very old timestamps', () => { - const now = Date.now(); - // return falsey value for long ago dates that don't update - view.$el.attr('data-timestamp', now - 8 * 24 * 60 * 60 * 1000); - view.update(); - assert.notOk(view.delay); - }); - }); -}); diff --git a/ts/backbone/views/Lightbox.ts b/ts/backbone/views/Lightbox.ts index 011fec513133..04da21003608 100644 --- a/ts/backbone/views/Lightbox.ts +++ b/ts/backbone/views/Lightbox.ts @@ -2,7 +2,7 @@ export const show = (element: HTMLElement): void => { const container: HTMLDivElement | null = document.querySelector( '.lightbox-container' ); - if (container === null) { + if (!container) { throw new TypeError("'.lightbox-container' is required"); } // tslint:disable-next-line:no-inner-html @@ -15,7 +15,7 @@ export const hide = (): void => { const container: HTMLDivElement | null = document.querySelector( '.lightbox-container' ); - if (container === null) { + if (!container) { return; } // tslint:disable-next-line:no-inner-html diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 23003685fed1..03524306ff61 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -2,13 +2,13 @@ import React from 'react'; import classNames from 'classnames'; import { getInitials } from '../util/getInitials'; -import { Localizer } from '../types/Util'; +import { LocalizerType } from '../types/Util'; interface Props { avatarPath?: string; color?: string; conversationType: 'group' | 'direct'; - i18n: Localizer; + i18n: LocalizerType; noteToSelf?: boolean; name?: string; phoneNumber?: string; @@ -44,9 +44,8 @@ export class Avatar extends React.Component { public renderImage() { const { avatarPath, i18n, name, phoneNumber, profileName } = this.props; const { imageBroken } = this.state; - const hasImage = avatarPath && !imageBroken; - if (!hasImage) { + if (!avatarPath || imageBroken) { return null; } diff --git a/ts/components/CaptionEditor.tsx b/ts/components/CaptionEditor.tsx index 1902a51c04d4..309c154b9944 100644 --- a/ts/components/CaptionEditor.tsx +++ b/ts/components/CaptionEditor.tsx @@ -3,13 +3,13 @@ import React from 'react'; import * as GoogleChrome from '../util/GoogleChrome'; -import { AttachmentType } from './conversation/types'; +import { AttachmentType } from '../types/Attachment'; -import { Localizer } from '../types/Util'; +import { LocalizerType } from '../types/Util'; interface Props { attachment: AttachmentType; - i18n: Localizer; + i18n: LocalizerType; url: string; caption?: string; onSave?: (caption: string) => void; @@ -21,15 +21,15 @@ interface State { } export class CaptionEditor extends React.Component { - private handleKeyUpBound: ( + private readonly handleKeyUpBound: ( event: React.KeyboardEvent ) => void; - private setFocusBound: () => void; - // TypeScript doesn't like our React.Ref typing here, so we omit it - private captureRefBound: () => void; - private onChangeBound: () => void; - private onSaveBound: () => void; - private inputRef: React.Ref | null; + private readonly setFocusBound: () => void; + private readonly onChangeBound: ( + event: React.FormEvent + ) => void; + private readonly onSaveBound: () => void; + private readonly inputRef: React.RefObject; constructor(props: Props) { super(props); @@ -41,10 +41,16 @@ export class CaptionEditor extends React.Component { this.handleKeyUpBound = this.handleKeyUp.bind(this); this.setFocusBound = this.setFocus.bind(this); - this.captureRefBound = this.captureRef.bind(this); this.onChangeBound = this.onChange.bind(this); this.onSaveBound = this.onSave.bind(this); - this.inputRef = null; + this.inputRef = React.createRef(); + } + + public componentDidMount() { + // Forcing focus after a delay due to some focus contention with ConversationView + setTimeout(() => { + this.setFocus(); + }, 200); } public handleKeyUp(event: React.KeyboardEvent) { @@ -61,21 +67,11 @@ export class CaptionEditor extends React.Component { } public setFocus() { - if (this.inputRef) { - // @ts-ignore - this.inputRef.focus(); + if (this.inputRef.current) { + this.inputRef.current.focus(); } } - public captureRef(ref: React.Ref) { - this.inputRef = ref; - - // Forcing focus after a delay due to some focus contention with ConversationView - setTimeout(() => { - this.setFocus(); - }, 200); - } - public onSave() { const { onSave } = this.props; const { caption } = this.state; @@ -124,6 +120,7 @@ export class CaptionEditor extends React.Component { public render() { const { i18n, close } = this.props; const { caption } = this.state; + const onKeyUp = close ? this.handleKeyUpBound : undefined; return (
{
{caption ? ( diff --git a/ts/components/ContactListItem.tsx b/ts/components/ContactListItem.tsx index 936ba4b4c270..d53ce172bc86 100644 --- a/ts/components/ContactListItem.tsx +++ b/ts/components/ContactListItem.tsx @@ -4,17 +4,17 @@ import classNames from 'classnames'; import { Avatar } from './Avatar'; import { Emojify } from './conversation/Emojify'; -import { Localizer } from '../types/Util'; +import { LocalizerType } from '../types/Util'; interface Props { phoneNumber: string; isMe?: boolean; name?: string; - color?: string; + color: string; verified: boolean; profileName?: string; avatarPath?: string; - i18n: Localizer; + i18n: LocalizerType; onClick?: () => void; } diff --git a/ts/components/ConversationListItem.md b/ts/components/ConversationListItem.md index 25bdeba9c40c..e350b17a2201 100644 --- a/ts/components/ConversationListItem.md +++ b/ts/components/ConversationListItem.md @@ -3,6 +3,7 @@ ```jsx console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> @@ -23,6 +24,7 @@ ```jsx console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> @@ -43,6 +45,7 @@ ```jsx console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> @@ -65,6 +68,7 @@
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -139,17 +147,19 @@
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -173,6 +183,7 @@
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -214,6 +227,7 @@ ```jsx console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> @@ -235,23 +249,25 @@ We don't want Jumbomoji or links.
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -266,6 +282,7 @@ We only show one line.
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -347,6 +369,7 @@ On platforms that show scrollbars all the time, this is true all the time.
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -378,43 +402,47 @@ On platforms that show scrollbars all the time, this is true all the time.
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
@@ -427,26 +455,29 @@ On platforms that show scrollbars all the time, this is true all the time.
console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} /> console.log('onClick')} + onClick={result => console.log('onClick', result)} i18n={util.i18n} />
diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 9383a12b7b87..d9d6acd7ec6e 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -7,14 +7,15 @@ import { Timestamp } from './conversation/Timestamp'; import { ContactName } from './conversation/ContactName'; import { TypingAnimation } from './conversation/TypingAnimation'; -import { Localizer } from '../types/Util'; +import { LocalizerType } from '../types/Util'; -interface Props { +export type PropsData = { + id: string; phoneNumber: string; + color?: string; profileName?: string; name?: string; - color?: string; - conversationType: 'group' | 'direct'; + type: 'group' | 'direct'; avatarPath?: string; isMe: boolean; @@ -27,17 +28,21 @@ interface Props { status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; text: string; }; +}; - i18n: Localizer; - onClick?: () => void; -} +type PropsHousekeeping = { + i18n: LocalizerType; + onClick?: (id: string) => void; +}; -export class ConversationListItem extends React.Component { +type Props = PropsData & PropsHousekeeping; + +export class ConversationListItem extends React.PureComponent { public renderAvatar() { const { avatarPath, color, - conversationType, + type, i18n, isMe, name, @@ -51,7 +56,7 @@ export class ConversationListItem extends React.Component { avatarPath={avatarPath} color={color} noteToSelf={isMe} - conversationType={conversationType} + conversationType={type} i18n={i18n} name={name} phoneNumber={phoneNumber} @@ -130,10 +135,10 @@ export class ConversationListItem extends React.Component { public renderMessage() { const { lastMessage, isTyping, unreadCount, i18n } = this.props; - if (!lastMessage && !isTyping) { return null; } + const text = lastMessage && lastMessage.text ? lastMessage.text : ''; return (
@@ -149,7 +154,7 @@ export class ConversationListItem extends React.Component { ) : ( { } public render() { - const { unreadCount, onClick, isSelected } = this.props; + const { unreadCount, onClick, id, isSelected } = this.props; return (
{ + if (onClick) { + onClick(id); + } + }} className={classNames( 'module-conversation-list-item', unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null, diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx index 00389469e5c1..d5a353fd82ba 100644 --- a/ts/components/Intl.tsx +++ b/ts/components/Intl.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import { Localizer, RenderTextCallback } from '../types/Util'; +import { LocalizerType, RenderTextCallbackType } from '../types/Util'; type FullJSX = Array | JSX.Element | string; interface Props { /** The translation string id */ id: string; - i18n: Localizer; + i18n: LocalizerType; components?: Array; - renderText?: RenderTextCallback; + renderText?: RenderTextCallbackType; } export class Intl extends React.Component { @@ -17,7 +17,7 @@ export class Intl extends React.Component { renderText: ({ text }) => text, }; - public getComponent(index: number): FullJSX | null { + public getComponent(index: number): FullJSX | undefined { const { id, components } = this.props; if (!components || !components.length || components.length <= index) { @@ -26,7 +26,7 @@ export class Intl extends React.Component { `Error: Intl missing provided components for id ${id}, index ${index}` ); - return null; + return; } return components[index]; diff --git a/ts/components/LeftPane.md b/ts/components/LeftPane.md new file mode 100644 index 000000000000..a9fd7f3d8494 --- /dev/null +++ b/ts/components/LeftPane.md @@ -0,0 +1,168 @@ +#### With search results + +```jsx +window.searchResults = {}; +window.searchResults.conversations = [ + { + id: 'convo1', + name: 'Everyone 🌆', + conversationType: 'group', + phoneNumber: '(202) 555-0011', + avatarPath: util.landscapeGreenObjectUrl, + lastUpdated: Date.now() - 5 * 60 * 1000, + lastMessage: { + text: 'The rabbit hopped silently in the night.', + status: 'sent', + }, + }, + { + id: 'convo2', + name: 'Everyone Else 🔥', + conversationType: 'direct', + phoneNumber: '(202) 555-0012', + avatarPath: util.landscapePurpleObjectUrl, + lastUpdated: Date.now() - 5 * 60 * 1000, + lastMessage: { + text: "What's going on?", + status: 'error', + }, + }, + { + id: 'convo3', + name: 'John the Turtle', + conversationType: 'direct', + phoneNumber: '(202) 555-0021', + lastUpdated: Date.now() - 24 * 60 * 60 * 1000, + lastMessage: { + text: 'I dunno', + }, + }, + { + id: 'convo4', + name: 'The Fly', + conversationType: 'direct', + phoneNumber: '(202) 555-0022', + avatarPath: util.pngObjectUrl, + lastUpdated: Date.now(), + lastMessage: { + text: 'Gimme!', + }, + }, +]; + +window.searchResults.contacts = [ + { + id: 'contact1', + name: 'The one Everyone', + conversationType: 'direct', + phoneNumber: '(202) 555-0013', + avatarPath: util.gifObjectUrl, + }, + { + id: 'contact2', + e: 'No likey everyone', + conversationType: 'direct', + phoneNumber: '(202) 555-0014', + color: 'red', + }, +]; + +window.searchResults.messages = [ + { + from: { + isMe: true, + avatarPath: util.gifObjectUrl, + }, + to: { + name: 'Mr. Fire 🔥', + phoneNumber: '(202) 555-0015', + }, + id: '1-guid-guid-guid-guid-guid', + conversationId: '(202) 555-0015', + receivedAt: Date.now() - 5 * 60 * 1000, + snippet: '<>Everyone<>! Get in!', + }, + { + from: { + name: 'Jon ❄️', + phoneNumber: '(202) 555-0016', + color: 'green', + }, + to: { + isMe: true, + }, + id: '2-guid-guid-guid-guid-guid', + conversationId: '(202) 555-0016', + snippet: 'Why is <>everyone<> so frustrated?', + receivedAt: Date.now() - 20 * 60 * 1000, + }, + { + from: { + name: 'Someone', + phoneNumber: '(202) 555-0011', + color: 'green', + avatarPath: util.pngObjectUrl, + }, + to: { + name: "Y'all 🌆", + }, + id: '3-guid-guid-guid-guid-guid', + conversationId: 'EveryoneGroupID', + snippet: 'Hello, <>everyone<>! Woohooo!', + receivedAt: Date.now() - 24 * 60 * 1000, + }, + { + from: { + isMe: true, + avatarPath: util.gifObjectUrl, + }, + to: { + name: "Y'all 🌆", + }, + id: '4-guid-guid-guid-guid-guid', + conversationId: 'EveryoneGroupID', + snippet: 'Well, <>everyone<>, happy new year!', + receivedAt: Date.now() - 24 * 60 * 1000, + }, +]; + + + console.log('openConversation', result)} + openMessage={result => console.log('onClickMessage', result)} + renderMainHeader={() => ( + console.log('search', result)} + updateSearch={result => console.log('updateSearch', result)} + clearSearch={result => console.log('clearSearch', result)} + i18n={util.i18n} + /> + )} + i18n={util.i18n} + /> +; +``` + +#### With just conversations + +```jsx + + console.log('openConversation', result)} + openMessage={result => console.log('onClickMessage', result)} + renderMainHeader={() => ( + console.log('search', result)} + updateSearch={result => console.log('updateSearch', result)} + clearSearch={result => console.log('clearSearch', result)} + i18n={util.i18n} + /> + )} + i18n={util.i18n} + /> + +``` diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx new file mode 100644 index 000000000000..8175f288b603 --- /dev/null +++ b/ts/components/LeftPane.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { + ConversationListItem, + PropsData as ConversationListItemPropsType, +} from './ConversationListItem'; +import { + PropsData as SearchResultsProps, + SearchResults, +} from './SearchResults'; +import { LocalizerType } from '../types/Util'; + +export interface Props { + conversations?: Array; + searchResults?: SearchResultsProps; + i18n: LocalizerType; + + // Action Creators + startNewConversation: () => void; + openConversationInternal: (id: string, messageId?: string) => void; + + // Render Props + renderMainHeader: () => JSX.Element; +} + +export class LeftPane extends React.Component { + public renderList() { + const { + conversations, + i18n, + openConversationInternal, + startNewConversation, + searchResults, + } = this.props; + + if (searchResults) { + return ( + + ); + } + + return ( +
+ {(conversations || []).map(conversation => ( + + ))} +
+ ); + } + + public render() { + const { renderMainHeader } = this.props; + + return ( +
+
{renderMainHeader()}
+ {this.renderList()} +
+ ); + } +} diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 05cda90b03b3..1f46fac2b564 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -8,7 +8,7 @@ import is from '@sindresorhus/is'; import * as GoogleChrome from '../util/GoogleChrome'; import * as MIME from '../types/MIME'; -import { Localizer } from '../types/Util'; +import { LocalizerType } from '../types/Util'; const Colors = { TEXT_SECONDARY: '#bbb', @@ -26,7 +26,7 @@ const colorSVG = (url: string, color: string) => { interface Props { close: () => void; contentType: MIME.MIMEType | undefined; - i18n: Localizer; + i18n: LocalizerType; objectURL: string; caption?: string; onNext?: () => void; @@ -164,17 +164,17 @@ const Icon = ({ ); export class Lightbox extends React.Component { - private containerRef: HTMLDivElement | null = null; - private videoRef: HTMLVideoElement | null = null; - - private captureVideoBound: (element: HTMLVideoElement) => void; - private playVideoBound: () => void; + private readonly containerRef: React.RefObject; + private readonly videoRef: React.RefObject; + private readonly playVideoBound: () => void; constructor(props: Props) { super(props); - this.captureVideoBound = this.captureVideo.bind(this); this.playVideoBound = this.playVideo.bind(this); + + this.videoRef = React.createRef(); + this.containerRef = React.createRef(); } public componentDidMount() { @@ -189,20 +189,21 @@ export class Lightbox extends React.Component { document.removeEventListener('keyup', this.onKeyUp, useCapture); } - public captureVideo(element: HTMLVideoElement) { - this.videoRef = element; - } - public playVideo() { if (!this.videoRef) { return; } - if (this.videoRef.paused) { + const { current } = this.videoRef; + if (!current) { + return; + } + + if (current.paused) { // tslint:disable-next-line no-floating-promises - this.videoRef.play(); + current.play(); } else { - this.videoRef.pause(); + current.pause(); } } @@ -221,7 +222,7 @@ export class Lightbox extends React.Component {
@@ -259,14 +260,14 @@ export class Lightbox extends React.Component { ); } - private renderObject = ({ + private readonly renderObject = ({ objectURL, contentType, i18n, }: { objectURL: string; contentType: MIME.MIMEType; - i18n: Localizer; + i18n: LocalizerType; }) => { const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType); if (isImageTypeSupported) { @@ -285,7 +286,7 @@ export class Lightbox extends React.Component { return (