diff --git a/.eslintignore b/.eslintignore index cc4758e2c..4abdcc219 100644 --- a/.eslintignore +++ b/.eslintignore @@ -41,6 +41,7 @@ ts/**/*.js !js/views/attachment_view.js !js/views/backbone_wrapper_view.js !js/views/clear_data_view.js +!js/views/contact_list_view.js !js/views/conversation_search_view.js !js/views/conversation_view.js !js/views/debug_log_view.js @@ -48,6 +49,7 @@ ts/**/*.js !js/views/inbox_view.js !js/views/message_view.js !js/views/settings_view.js +!js/views/timestamp_view.js !test/backup_test.js !test/views/attachment_view_test.js !libtextsecure/message_receiver.js diff --git a/Gruntfile.js b/Gruntfile.js index 8531037b4..34563a4f0 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -112,8 +112,10 @@ module.exports = function(grunt) { '!js/views/conversation_view.js', '!js/views/debug_log_view.js', '!js/views/file_input_view.js', + '!js/views/timestamp_view.js', '!js/views/message_view.js', '!js/views/settings_view.js', + '!js/views/contact_list_view.js', '!js/models/conversations.js', '!js/models/messages.js', '!js/WebAudioRecorderMp3.js', diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a315b627a..d860fa79f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -553,8 +553,14 @@ "message": "Select a contact or group to start chatting." }, "contactAvatarAlt": { - "message": "Contact avatar", - "description": "Used in the alt tag for the image avatar of a contact" + "message": "Avatar for contact $name$", + "description": "Used in the alt tag for the image avatar of a contact", + "placeholders": { + "name": { + "content": "$1", + "example": "John" + } + } }, "sendMessageToContact": { "message": "Send Message", @@ -730,6 +736,10 @@ "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" }, + "imageAttachmentAlt": { + "message": "Image attached to message", + "description": "Used in alt tag of image attachment" + }, "lightboxImageAlt": { "message": "Image sent in conversation", "description": "Used in the alt tag for the image shown in a full-screen lightbox view" @@ -956,20 +966,44 @@ "description": "Informational text displayed if a sync operation times out." }, "timestamp_s": { - "description": "Brief timestamp for messages sent less than a minute ago. Displayed in the conversation list and message bubble.", - "message": "now" + "message": "now", + "description": "Brief timestamp for messages sent less than a minute ago. Displayed in the conversation list and message bubble." }, "timestamp_m": { - "description": "Brief timestamp for messages sent about one minute ago. Displayed in the conversation list and message bubble.", - "message": "1 minute" + "message": "1 minute", + "description": "Brief timestamp for messages sent about one minute ago. Displayed in the conversation list and message bubble." }, "timestamp_h": { - "description": "Brief timestamp for messages sent about one hour ago. Displayed in the conversation list and message bubble.", - "message": "1 hour" + "message": "1 hour", + "description": "Brief timestamp for messages sent about one hour ago. Displayed in the conversation list and message bubble." + }, + "hoursAgo": { + "message": "$hours$ hr ago", + "description": "Contracted form of 'X hours ago' which works both for singular and plural", + "placeholders": { + "hours": { + "content": "$1", + "example": "2" + } + } + }, + "minutesAgo": { + "message": "$minutes$ min ago", + "description": "Contracted form of 'X minutes ago' which works both for singular and plural", + "placeholders": { + "minutes": { + "content": "$1", + "example": "10" + } + } + }, + "justNow": { + "message": "now", + "description": "Shown if a message is very recent, less than 60 seconds old" }, "timestampFormat_M": { - "description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'.", - "message": "MMM D" + "message": "MMM D", + "description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'." }, "unblockToSend": { "message": "Unblock this contact to send a message.", diff --git a/images/check-circle-outline.svg b/images/check-circle-outline.svg index 61a9db740..f00955ec9 100644 --- a/images/check-circle-outline.svg +++ b/images/check-circle-outline.svg @@ -1 +1,12 @@ - \ No newline at end of file + + + + check + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/double-check.svg b/images/double-check.svg index 37adae6a3..8a6958f98 100644 --- a/images/double-check.svg +++ b/images/double-check.svg @@ -1 +1,17 @@ - \ No newline at end of file + + + + double check + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/images/file-gradient.svg b/images/file-gradient.svg new file mode 100644 index 000000000..740f6dd6b --- /dev/null +++ b/images/file-gradient.svg @@ -0,0 +1,51 @@ + + + + File + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/sending.svg b/images/sending.svg new file mode 100644 index 000000000..68de68f85 --- /dev/null +++ b/images/sending.svg @@ -0,0 +1,12 @@ + + + + sending + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/images/timer-00.svg b/images/timer-00.svg new file mode 100644 index 000000000..f35deed54 --- /dev/null +++ b/images/timer-00.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 00/timer00_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-05.svg b/images/timer-05.svg new file mode 100644 index 000000000..e65f1daa1 --- /dev/null +++ b/images/timer-05.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 05/timer05_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-10.svg b/images/timer-10.svg new file mode 100644 index 000000000..d60a19ecf --- /dev/null +++ b/images/timer-10.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 10/timer10_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-15.svg b/images/timer-15.svg new file mode 100644 index 000000000..91091c24d --- /dev/null +++ b/images/timer-15.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 15/timer15_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-20.svg b/images/timer-20.svg new file mode 100644 index 000000000..26057b7ed --- /dev/null +++ b/images/timer-20.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 20/timer20_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-25.svg b/images/timer-25.svg new file mode 100644 index 000000000..9bda6eef7 --- /dev/null +++ b/images/timer-25.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 25/timer25_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-30.svg b/images/timer-30.svg new file mode 100644 index 000000000..d87487163 --- /dev/null +++ b/images/timer-30.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 30/timer30_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-35.svg b/images/timer-35.svg new file mode 100644 index 000000000..0bf63fc86 --- /dev/null +++ b/images/timer-35.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 35/timer35_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-40.svg b/images/timer-40.svg new file mode 100644 index 000000000..7dbdfd16c --- /dev/null +++ b/images/timer-40.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 40/timer40_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-45.svg b/images/timer-45.svg new file mode 100644 index 000000000..4fa786352 --- /dev/null +++ b/images/timer-45.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 45/timer45_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-50.svg b/images/timer-50.svg new file mode 100644 index 000000000..2b5a00f32 --- /dev/null +++ b/images/timer-50.svg @@ -0,0 +1,11 @@ + + + + Icons/Timer 50/timer50_12 + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/images/timer-55.svg b/images/timer-55.svg new file mode 100644 index 000000000..6db8275c2 --- /dev/null +++ b/images/timer-55.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 55/timer55_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/timer-60.svg b/images/timer-60.svg new file mode 100644 index 000000000..edcbd5e75 --- /dev/null +++ b/images/timer-60.svg @@ -0,0 +1,10 @@ + + + + Icons/Timer 60/timer60_12 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/js/modules/signal.js b/js/modules/signal.js index ae2dceef9..0c32434c3 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -15,6 +15,7 @@ const Util = require('../../ts/util'); const { ContactDetail, } = require('../../ts/components/conversation/ContactDetail'); +const { ContactListItem } = require('../../ts/components/ContactListItem'); const { ContactName } = require('../../ts/components/conversation/ContactName'); const { ConversationTitle, @@ -105,6 +106,7 @@ exports.setup = (options = {}) => { const Components = { ContactDetail, + ContactListItem, ContactName, ConversationTitle, EmbeddedContact, diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index 9d525a216..547c13b08 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -1,5 +1,11 @@ +/* global Whisper: false */ +/* global i18n: false */ +/* global textsecure: false */ + +// eslint-disable-next-line func-names (function() { 'use strict'; + window.Whisper = window.Whisper || {}; Whisper.ContactListView = Whisper.ListView.extend({ @@ -11,36 +17,45 @@ events: { click: 'showIdentity', }, - initialize: function(options) { + initialize(options) { this.ourNumber = textsecure.storage.user.getNumber(); this.listenBack = options.listenBack; this.listenTo(this.model, 'change', this.render); }, - render_attributes: function() { - if (this.model.id === this.ourNumber) { - return { - title: i18n('me'), - number: this.model.getNumber(), - avatar: this.model.getAvatar(), - }; + render() { + if (this.contactView) { + this.contactView.remove(); + this.contactView = null; } - return { - class: 'clickable', - title: this.model.getTitle(), - number: this.model.getNumber(), - avatar: this.model.getAvatar(), - profileName: this.model.getProfileName(), - isVerified: this.model.isVerified(), - verified: i18n('verified'), - }; + const avatar = this.model.getAvatar(); + const avatarPath = avatar && avatar.url; + const color = avatar && avatar.color; + const isMe = this.ourNumber === this.model.id; + + this.contactView = new Whisper.ReactWrapperView({ + className: 'contact-wrapper', + Component: window.Signal.Components.ContactListItem, + props: { + isMe, + color, + avatarPath, + phoneNumber: this.model.getNumber(), + name: this.model.getName(), + profileName: this.model.getProfileName(), + verified: this.model.isVerified(), + onClick: this.showIdentity.bind(this), + }, + }); + this.$el.append(this.contactView.el); + return this; }, - showIdentity: function() { + showIdentity() { if (this.model.id === this.ourNumber) { return; } - var view = new Whisper.KeyVerificationPanelView({ + const view = new Whisper.KeyVerificationPanelView({ model: this.model, }); this.listenBack(view); diff --git a/js/views/timestamp_view.js b/js/views/timestamp_view.js index 070ec6fee..c443f5390 100644 --- a/js/views/timestamp_view.js +++ b/js/views/timestamp_view.js @@ -1,28 +1,84 @@ +/* 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: function(options) { + initialize() { extension.windows.onClosed(this.clearTimeout.bind(this)); }, - update: function() { + update() { this.clearTimeout(); - var millis_now = Date.now(); - var millis = this.$el.data('timestamp'); + const millisNow = Date.now(); + let millis = this.$el.data('timestamp'); if (millis === '') { return; } - if (millis >= millis_now) { - millis = millis_now; + if (millis >= millisNow) { + millis = millisNow; } - var result = this.getRelativeTimeSpanString(millis); + const result = this.getRelativeTimeSpanString(millis); + this.delay = this.getDelay(millis); this.$el.text(result); - var timestamp = moment(millis); + const timestamp = moment(millis); this.$el.attr('title', timestamp.format('llll')); - var millis_since = millis_now - millis; if (this.delay) { if (this.delay < 0) { this.delay = 1000; @@ -30,70 +86,44 @@ this.timeout = setTimeout(this.update.bind(this), this.delay); } }, - clearTimeout: function() { + clearTimeout() { clearTimeout(this.timeout); }, - getRelativeTimeSpanString: function(timestamp_) { + getRelativeTimeSpanString(timestamp) { + return getRelativeTimeSpanString(timestamp); + }, + getDelay(rawTimestamp) { // Convert to moment timestamp if it isn't already - var timestamp = moment(timestamp_), - now = moment(), - timediff = moment.duration(now - timestamp); + const timestamp = moment(rawTimestamp); + const now = moment(); + const timediff = moment.duration(now - timestamp); if (timediff.years() > 0) { - this.delay = null; - return timestamp.format(this._format.y); + return null; } else if (timediff.months() > 0 || timediff.days() > 6) { - this.delay = null; - return timestamp.format(this._format.M); + return null; } else if (timediff.days() > 0) { - this.delay = moment(timestamp) + return moment(timestamp) .add(timediff.days() + 1, 'd') .diff(now); - return timestamp.format(this._format.d); - } else if (timediff.hours() > 1) { - this.delay = moment(timestamp) + } else if (timediff.hours() >= 1) { + return moment(timestamp) .add(timediff.hours() + 1, 'h') .diff(now); - return this.relativeTime(timediff.hours(), 'h'); - } else if (timediff.hours() === 1) { - this.delay = moment(timestamp) - .add(timediff.hours() + 1, 'h') - .diff(now); - return this.relativeTime(timediff.hours(), 'h'); - } else if (timediff.minutes() > 1) { - this.delay = moment(timestamp) + } else if (timediff.minutes() >= 1) { + return moment(timestamp) .add(timediff.minutes() + 1, 'm') .diff(now); - return this.relativeTime(timediff.minutes(), 'm'); - } else if (timediff.minutes() === 1) { - this.delay = moment(timestamp) - .add(timediff.minutes() + 1, 'm') - .diff(now); - return this.relativeTime(timediff.minutes(), 'm'); - } else { - this.delay = moment(timestamp) - .add(1, 'm') - .diff(now); - return this.relativeTime(timediff.seconds(), 's'); } - }, - relativeTime: function(number, string) { - return moment.duration(number, string).humanize(); - }, - _format: { - y: 'll', - M: i18n('timestampFormat_M') || 'MMM D', - d: 'ddd', + + return moment(timestamp) + .add(1, 'm') + .diff(now); }, }); Whisper.ExtendedTimestampView = Whisper.TimestampView.extend({ - relativeTime: function(number, string, isFuture) { - return moment.duration(-1 * number, string).humanize(string !== 's'); - }, - _format: { - y: 'lll', - M: (i18n('timestampFormat_M') || 'MMM D') + ' LT', - d: 'ddd LT', + getRelativeTimeSpanString(timestamp) { + return getRelativeTimeSpanString(timestamp, { extended: true }); }, }); })(); diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index c97eadd53..e6c7d3ed8 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -125,7 +125,7 @@ } .discussion-container { - background-color: #eee; + background-color: 'white'; } .key-verification { @@ -743,394 +743,13 @@ span.status { } } -.embedded-contact { - margin-top: -9px; - margin-left: -12px; - margin-right: -12px; - - cursor: pointer; - - button { - @include button-reset; - } - - .first-line { - display: flex; - flex-direction: row; - align-items: stretch; - margin: 8px; - - .image-container { - flex: initial; - min-width: 50px; - width: 50px; - height: 50px; - - text-align: center; - display: flex; - align-items: center; - justify-content: center; - - object-fit: cover; - - img { - border-radius: 50%; - width: 100%; - height: 100%; - object-fit: cover; - } - - .default-avatar { - border-radius: 50%; - width: 100%; - height: 100%; - background-color: gray; - color: white; - font-size: 25px; - line-height: 52px; - } - } - - .text-container { - flex-grow: 1; - margin-left: 8px; - - .contact-name { - font-size: 16px; - font-weight: 300; - margin-top: 3px; - color: $blue; - } - - .contact-method { - font-size: 14px; - margin-top: 6px; - } - } - } - - .send-message { - margin-top: 8px; - margin-bottom: 3px; - padding: 11px; - border-top: 1px solid $grey_l1_5; - border-bottom: 1px solid $grey_l1_5; - - color: $blue; - font-weight: 300; - display: flex; - flex-direction: column; - align-items: center; - - .inner { - display: flex; - align-items: center; - } - - .bubble-icon { - height: 17px; - width: 18px; - display: inline-block; - margin-right: 5px; - @include color-svg('../images/chat-bubble.svg', $blue); - } - } -} - -.incoming .embedded-contact { - color: white; - - .text-container .contact-name { - color: white; - } - - .send-message { - color: white; - // We would like to use these border colors for incoming messages, but the version - // of Chromium in our Electron version doesn't render these appropriately. Both - // borders disappear for some reason, and it seems to have to do with transparency. - // border-top: 1px solid rgba(255, 255, 255, 0.5); - // border-bottom: 1px solid rgba(255, 255, 255, 0.5); - - .bubble-icon { - background-color: white; - } - } -} - -.group .incoming .embedded-contact { - margin-top: -2px; -} - .contact-detail-pane { overflow-y: scroll; + padding-top: 40px; + padding-bottom: 40px; } -.contact-detail-component { - text-align: center; - max-width: 300px; - margin-left: auto; - margin-right: auto; - - button { - @include button-reset; - } - - .image-container { - height: 80px; - width: 80px; - margin-bottom: 4px; - - text-align: center; - display: inline-block; - object-fit: cover; - - img { - border-radius: 50%; - width: 100%; - height: 100%; - object-fit: cover; - } - - .default-avatar { - border-radius: 50%; - width: 100%; - height: 100%; - background-color: gray; - color: white; - font-size: 50px; - line-height: 82px; - } - } - - .contact-name { - font-size: 20px; - font-weight: bold; - } - - .contact-method { - font-size: 14px; - margin-top: 10px; - } - - .send-message { - cursor: pointer; - - border-radius: 4px; - background-color: $blue; - display: inline-block; - padding: 6px; - margin-top: 20px; - - // TODO: border - // TODO: gradient - - color: white; - - flex-direction: column; - align-items: center; - - .inner { - display: flex; - align-items: center; - } - - .bubble-icon { - height: 17px; - width: 18px; - display: inline-block; - margin-right: 5px; - @include color-svg('../images/chat-bubble.svg', white); - } - } - - .additional-contact { - text-align: left; - border-top: 1px solid $grey_l1_5; - margin-top: 15px; - padding-top: 8px; - - .type { - color: rgba(0, 0, 0, 0.5); - font-size: 12px; - margin-bottom: 3px; - } - } -} - -.conversation .contact-detail-component { - margin-top: 40px; - margin-bottom: 40px; -} - -.quoted-message { - @include message-replies-colors; - @include twenty-percent-colors; - - &.no-click { - cursor: auto; - } - - position: relative; - cursor: pointer; - display: flex; - flex-direction: row; - align-items: stretch; - overflow: hidden; - - border-radius: 2px; - background-color: #eee; - position: relative; - - margin-right: $android-bubble-quote-padding - - $android-bubble-padding-horizontal; - margin-left: $android-bubble-quote-padding - - $android-bubble-padding-horizontal; - margin-bottom: 0.5em; - - // Accent color border: - border-left-width: 3px; - border-left-style: solid; - - .primary { - flex-grow: 1; - padding-left: 10px; - padding-right: 10px; - padding-top: 6px; - padding-bottom: 6px; - - // Will turn on in the iOS theme. This extra element is necessary because the iOS - // theme requires text that isn't used at all in the Android Theme - .ios-label { - display: none; - } - - .author { - font-weight: bold; - margin-bottom: 0.3em; - @include text-colors; - - .profile-name { - font-size: smaller; - } - } - - .text { - white-space: pre-wrap; - 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 - } - - .type-label { - font-style: italic; - font-size: 12px; - } - - .filename-label { - font-size: 12px; - } - } - - .close-container { - position: absolute; - top: 4px; - right: 4px; - height: 16px; - width: 16px; - - background-color: rgba(255, 255, 255, 0.75); - border-radius: 50%; - - .close-button { - width: 100%; - height: 100%; - @include color-svg('../images/x.svg', $grey); - } - } - - .icon-container { - flex: initial; - min-width: 50px; - width: 50px; - height: 50px; - position: relative; - - .circle-background { - position: absolute; - left: 6px; - right: 6px; - top: 6px; - bottom: 6px; - - border-radius: 50%; - @include avatar-colors; - &.white { - background-color: white; - } - } - - .icon { - position: absolute; - left: 12px; - right: 12px; - top: 12px; - bottom: 12px; - - &.file { - @include color-svg('../images/file.svg', white); - } - &.image { - @include color-svg('../images/image.svg', white); - } - &.microphone { - @include color-svg('../images/microphone.svg', white); - } - &.play { - @include color-svg('../images/play.svg', white); - } - &.movie { - @include color-svg('../images/movie.svg', white); - } - - @include avatar-colors; - } - - .inner { - position: relative; - - height: 50px; - text-align: center; - display: flex; - align-items: center; - justify-content: center; - - object-fit: cover; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - } -} - -// We only add margin if there's no 'sender' element beforehand, which is only possible -// on incoming messages, and only in groups (when we're not in a .private conversation). -.outgoing .quoted-message, -.private .incoming .quoted-message { - margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical; -} - -.bottom-bar .quoted-message { +.bottom-bar .module-quote { margin: 0; } diff --git a/stylesheets/_emoji.scss b/stylesheets/_emoji.scss index e6d17989d..f41bd7d3b 100644 --- a/stylesheets/_emoji.scss +++ b/stylesheets/_emoji.scss @@ -43,6 +43,7 @@ span.emoji-inner { img.emoji { width: 1em; height: 1em; + margin-bottom: -1px; } img.emoji.small { @@ -50,16 +51,16 @@ img.emoji.small { height: 1.25em; } img.emoji.medium { - width: 1.5em; - height: 1.5em; -} -img.emoji.large { width: 1.75em; height: 1.75em; } +img.emoji.large { + width: 2.5em; + height: 2.5em; +} img.emoji.jumbo { - width: 2em; - height: 2em; + width: 3em; + height: 3em; } // we need these, or we'll make conversation items too big in the left-nav diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index fceef5ada..842427c94 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -445,6 +445,12 @@ $avatar-size: 44px; height: 1.25em; vertical-align: text-bottom; } + + .body-wrapper { + overflow-x: hidden; + overflow-y: hidden; + text-overflow: ellipsis; + } } .recipients-input { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss new file mode 100644 index 000000000..1bd24ca4d --- /dev/null +++ b/stylesheets/_modules.scss @@ -0,0 +1,1057 @@ +// Using BEM syntax explained here: https://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/ + +$color-signal-blue: #2090ea; +$color-core-green: #4caf50; +$color-core-red: #f44336; + +$color-white: #ffffff; +$color-white-07: rgba($color-white, 0.7); +$color-white-075: rgba($color-white, 0.75); +$color-light-02: #f9fafa; +$color-light-10: #eeefef; +$color-light-35: #a4a6a9; +$color-light-45: #8b8e91; +$color-light-60: #62656a; +$color-light-90: #070c14; + +$color-dark-05: #efefef; +$color-dark-30: #a8a9aa; +$color-dark-55: #88898c; +$color-dark-60: #797a7c; +$color-dark-70: #414347; +$color-dark-85: #1a1c20; +$color-black: #000000; +$color-black-012: rgba($color-black, 0.12); + +// TODO: need the final color for grey conversations +$color-conversation-grey: #757575; +$color-conversation-blue: #1976d2; +$color-conversation-cyan: #00838f; +$color-conversation-deep_orange: #bf360c; +$color-conversation-green: #2e7d32; +$color-conversation-indigo: #3949ab; +$color-conversation-pink: #d81b60; +$color-conversation-purple: #8e24aa; +$color-conversation-red: #d32f2f; +$color-conversation-teal: #00796b; + +// Module: Message + +.module-message { + position: relative; + display: inline-block; + border-radius: 16px; + padding-right: 12px; + padding-left: 12px; + padding-top: 10px; + padding-bottom: 10px; + max-width: 370px; // 350 + left/right padding +} + +.module-message__attachment-container { + // Entirely to ensure that images are centered if they aren't full width of bubble + text-align: center; + position: relative; +} + +.module-message__img-attachment { + max-width: 370px; + margin-left: -12px; + margin-right: -12px; + margin-top: -10px; + // The padding on the bottom of the bubble produces three extra pixels of space at the + // bottom, so this doesn't match up with the padding numbers above. + margin-bottom: -13px; + border-radius: 16px; + box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.2), 0 0 4px 0 rgba(0, 0, 0, 0.08); + + object-fit: cover; + width: calc(100% + 24px); + min-width: 200px; + min-height: 150px; + max-height: 300px; + cursor: pointer; +} + +.module-message__img-overlay { + height: 48px; + background-image: linear-gradient( + to bottom, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0) 9%, + rgba(0, 0, 0, 0.02) 17%, + rgba(0, 0, 0, 0.05) 24%, + rgba(0, 0, 0, 0.08) 31%, + rgba(0, 0, 0, 0.12) 37%, + rgba(0, 0, 0, 0.16) 44%, + rgba(0, 0, 0, 0.2) 50%, + rgba(0, 0, 0, 0.24) 56%, + rgba(0, 0, 0, 0.28) 63%, + rgba(0, 0, 0, 0.32) 69%, + rgba(0, 0, 0, 0.35) 76%, + rgba(0, 0, 0, 0.38) 83%, + rgba(0, 0, 0, 0.4) 91%, + rgba(0, 0, 0, 0.4) + ); + position: absolute; + bottom: 0; + z-index: 1; + left: 0; + right: 0; + margin-left: -12px; + margin-right: -12px; + margin-bottom: -10px; + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; +} + +.module-message__img-attachment--with-content-below { + margin-bottom: 5px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; +} + +.module-message__img-attachment--with-content-above { + margin-top: 4px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; +} + +.module-message__audio-attachment { + margin-top: 2px; +} + +.module-message__audio-attachment--with-content-below { + margin-bottom: 5px; +} + +.module-message__audio-attachment--with-content-above { + margin-top: 6px; +} + +.module-message__generic-attachment { + display: flex; + flex-direction: row; +} + +.module-message__generic-attachment--with-content-below { + padding-bottom: 6px; +} + +.module-message__generic-attachment--with-content-above { + padding-top: 4px; +} + +.module-message__generic-attachment__icon { + background: url('../images/file-gradient.svg') no-repeat center; + height: 44px; + width: 56px; + margin-left: -13px; + margin-right: -14px; + margin-bottom: -4px; + + // So we can center the extension text inside this icon + display: flex; + flex-direction: row; + align-items: center; +} + +.module-message__generic-attachment__icon__extension { + font-size: 10px; + line-height: 13px; + letter-spacing: 0.1px; + text-transform: uppercase; + + // Along with flow layout in parent item, centers text + text-align: center; + width: 25px; + margin-left: auto; + margin-right: auto; + + // We don't have much room for text here, cut it off without ellipse + overflow-x: hidden; + white-space: nowrap; + text-overflow: clip; +} + +.module-message__generic-attachment__text { + flex-grow: 1; + margin-left: 8px; + // The width of the icon plus our 8px margin + max-width: calc(100% - 37px); +} + +.module-message__generic-attachment__file-name { + color: $color-light-90; + font-size: 14px; + line-height: 18px; + font-weight: 300; + margin-top: 2px; + + // Handling really long filenames - cut them off + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.module-message__generic-attachment__file-name--incoming { + color: $color-white; +} + +.module-message__generic-attachment__file-size { + color: $color-light-90; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + margin-top: 3px; +} + +.module-message__generic-attachment__file-size--incoming { + color: $color-white; +} + +.module-message__author { + color: $color-white; + font-size: 13px; + font-weight: 300; + line-height: 18px; + height: 18px; + overflow-x: hidden; + overflow-y: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.module-message__author__profile-name { + color: $color-white-07; + // TODO: finalize this font + font-size: 11px; + line-height: 18px; +} + +.module-message--outgoing { + background-color: $color-light-10; + float: right; +} + +// In case the color gets messed up +.module-message--incoming { + background-color: $color-conversation-grey; +} + +.module-message--incoming-grey { + background-color: $color-conversation-grey; +} +.module-message--incoming-blue { + background-color: $color-conversation-blue; +} +.module-message--incoming-cyan { + background-color: $color-conversation-cyan; +} +.module-message--incoming-deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-message--incoming-green { + background-color: $color-conversation-green; +} +.module-message--incoming-indigo { + background-color: $color-conversation-indigo; +} +.module-message--incoming-pink { + background-color: $color-conversation-pink; +} +.module-message--incoming-purple { + background-color: $color-conversation-purple; +} +.module-message--incoming-red { + background-color: $color-conversation-red; +} +.module-message--incoming-teal { + background-color: $color-conversation-teal; +} + +.module-message--with-image-only { + background-color: transparent; +} + +.module-message__text { + color: $color-light-90; + font-size: 14px; + line-height: 18px; + a { + text-decoration: underline; + color: $color-light-90; + } +} + +.module-message__text--incoming { + color: $color-white; + a { + text-decoration: underline; + color: $color-white; + } +} + +.module-message__metadata { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 3px; + margin-bottom: -3px; +} + +// With an image and no caption, this section needs to be on top of the image +.module-message__metadata--with-image-no-caption { + position: absolute; + bottom: 9px; + z-index: 1; + + width: 100%; + // Because this is absolutely positioned, we 100% is too big, take it down by parent + // padding sizes. + padding-right: 24px; +} + +.module-message__metadata__date { + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + color: $color-light-60; + text-transform: uppercase; +} +.module-message__metadata__date--incoming { + color: $color-white-07; +} +.module-message__metadata__date--with-image-no-caption { + color: $color-white; +} + +.module-message__metadata__timer { + width: 12px; + height: 12px; + display: inline-block; + margin-left: 6px; + margin-bottom: 2px; + @include color-svg('../images/timer-60.svg', $color-light-45); +} + +.module-message__metadata__timer--55 { + @include color-svg('../images/timer-55.svg', $color-light-45); +} +.module-message__metadata__timer--50 { + @include color-svg('../images/timer-50.svg', $color-light-45); +} +.module-message__metadata__timer--45 { + @include color-svg('../images/timer-45.svg', $color-light-45); +} +.module-message__metadata__timer--40 { + @include color-svg('../images/timer-40.svg', $color-light-45); +} +.module-message__metadata__timer--35 { + @include color-svg('../images/timer-35.svg', $color-light-45); +} +.module-message__metadata__timer--30 { + @include color-svg('../images/timer-30.svg', $color-light-45); +} +.module-message__metadata__timer--25 { + @include color-svg('../images/timer-25.svg', $color-light-45); +} +.module-message__metadata__timer--20 { + @include color-svg('../images/timer-20.svg', $color-light-45); +} +.module-message__metadata__timer--15 { + @include color-svg('../images/timer-15.svg', $color-light-45); +} +.module-message__metadata__timer--10 { + @include color-svg('../images/timer-10.svg', $color-light-45); +} +.module-message__metadata__timer--05 { + @include color-svg('../images/timer-05.svg', $color-light-45); +} +.module-message__metadata__timer--00 { + @include color-svg('../images/timer-00.svg', $color-light-45); +} + +.module-message__metadata__timer--incoming { + background-color: $color-white-07; +} + +// When status indicators are overlaid on top of an image, they use different colors +.module-message__metadata__timer--with-image-no-caption { + background-color: white; +} + +.module-message__metadata__spacer { + flex-grow: 1; +} + +.module-message__metadata__status-icon { + width: 12px; + height: 12px; + display: inline-block; + margin-left: 6px; + margin-bottom: 2px; +} + +.module-message__metadata__status-icon-sending { + @include color-svg('../images/sending.svg', $color-light-60); + animation: module-message__metdata__status-icon--spinning 4s linear infinite; +} + +@keyframes module-message__metdata__status-icon--spinning { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.module-message__metadata__status-icon-sent { + @include color-svg('../images/check-circle-outline.svg', $color-light-35); +} +.module-message__metadata__status-icon-delivered { + @include color-svg('../images/double-check.svg', $color-light-35); + width: 18px; +} +.module-message__metadata__status-icon-read { + @include color-svg('../images/double-check.svg', $color-light-35); + width: 18px; +} + +.module-message__metadata__status-icon-grey { + background-color: $color-conversation-grey; +} +.module-message__metadata__status-icon-blue { + background-color: $color-conversation-blue; +} +.module-message__metadata__status-icon-cyan { + background-color: $color-conversation-cyan; +} +.module-message__metadata__status-icon-deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-message__metadata__status-icon-green { + background-color: $color-conversation-green; +} +.module-message__metadata__status-icon-indigo { + background-color: $color-conversation-indigo; +} +.module-message__metadata__status-icon-pink { + background-color: $color-conversation-pink; +} +.module-message__metadata__status-icon-purple { + background-color: $color-conversation-purple; +} +.module-message__metadata__status-icon-red { + background-color: $color-conversation-red; +} +.module-message__metadata__status-icon-teal { + background-color: $color-conversation-teal; +} + +// When status indicators are overlaid on top of an image, they use different colors +.module-message__metadata__status-icon--with-image-no-caption { + background-color: white; +} + +.module-message__metadata__status-icon--read-with-image-no-caption { + background-color: $color-signal-blue; +} + +.module-message__send-message-button { + cursor: pointer; + font-weight: 300; + font-size: 13px; + line-height: 18px; + color: $color-signal-blue; + background-color: $color-light-02; + border: 1px solid $color-black-012; + + margin-top: 8px; + margin-bottom: -10px; + margin-left: -12px; + margin-right: -12px; + + text-align: center; + padding: 10px; + + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; +} + +.module-message__author-avatar { + position: absolute; + // This accounts for the weird extra 3px we get at the bottom of messages + bottom: -3px; + right: calc(100% + 4px); + + img { + height: 36px; + width: 36px; + border-radius: 18px; + object-fit: cover; + } +} + +.module-message__author-default-avatar { + position: absolute; + bottom: 0px; + right: calc(100% + 4px); + + height: 36px; + width: 36px; + background-color: $color-conversation-grey; + border-radius: 18px; + + display: flex; + flex-direction: row; + align-items: center; + text-align: center; +} + +.module-message__author-default-avatar__label { + width: 100%; + font-size: 18px; + color: $color-white; + + // Because it just doesn't look properly centered + padding-right: 1px; +} + +// Module: Quoted Reply + +.module-quote { + border-radius: 4px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + + margin-left: -6px; + margin-right: -6px; + margin-top: -4px; + margin-bottom: 5px; + + cursor: pointer; + display: flex; + flex-direction: row; + align-items: stretch; + overflow: hidden; + + border-left-width: 4px; + border-left-style: solid; +} + +.module-quote--no-click { + cursor: auto; +} + +.module-quote--with-content-above { + margin-top: 3px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.module-quote--incoming { + background-color: $color-white-075; + border-left-color: $color-white; +} + +.module-quote--outgoing-grey { + border-left-color: $color-conversation-grey; + background-color: rgba($color-conversation-grey, 0.25); +} +.module-quote--outgoing-blue { + border-left-color: $color-conversation-blue; + background-color: rgba($color-conversation-blue, 0.25); +} +.module-quote--outgoing-cyan { + border-left-color: $color-conversation-cyan; + background-color: rgba($color-conversation-cyan, 0.25); +} +.module-quote--outgoing-deep_orange { + border-left-color: $color-conversation-deep_orange; + background-color: rgba($color-conversation-deep_orange, 0.25); +} +.module-quote--outgoing-green { + border-left-color: $color-conversation-green; + background-color: rgba($color-conversation-green, 0.25); +} +.module-quote--outgoing-indigo { + border-left-color: $color-conversation-indigo; + background-color: rgba($color-conversation-indigo, 0.25); +} +.module-quote--outgoing-pink { + border-left-color: $color-conversation-pink; + background-color: rgba($color-conversation-pink, 0.25); +} +.module-quote--outgoing-purple { + border-left-color: $color-conversation-purple; + background-color: rgba($color-conversation-purple, 0.25); +} +.module-quote--outgoing-red { + border-left-color: $color-conversation-red; + background-color: rgba($color-conversation-red, 0.25); +} +.module-quote--outgoing-teal { + border-left-color: $color-conversation-teal; + background-color: rgba($color-conversation-teal, 0.25); +} + +.module-quote__primary { + flex-grow: 1; + padding-left: 8px; + padding-right: 8px; + padding-top: 7px; + padding-bottom: 7px; +} + +.module-quote__primary__author { + font-size: 13px; + line-height: 18px; + font-weight: 300; + color: $color-light-90; +} + +.module-quote__primary__profile-name { + font-size: smaller; +} + +.module-quote__primary__text { + font-size: 14px; + line-height: 18px; + color: $color-light-90; + + white-space: pre-wrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -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-quote__primary__type-label { + font-style: italic; + color: $color-light-90; + font-size: 13px; + line-height: 18px; +} + +.module-quote__primary__filename-label { + font-size: 12px; +} + +.module-quote__close-container { + position: absolute; + top: 4px; + right: 4px; + height: 16px; + width: 16px; + + background-color: rgba(255, 255, 255, 0.75); + border-radius: 50%; +} + +.module-quote__close-button { + width: 100%; + height: 100%; + @include color-svg('../images/x.svg', $grey); +} + +.module-quote__icon-container { + flex: initial; + min-width: 54px; + width: 54px; + position: relative; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.module-quote__icon-container__inner { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.module-quote__icon-container__circle-background { + display: flex; + align-items: center; + justify-content: center; + + height: 32px; + width: 32px; + border-radius: 50%; + background-color: white; +} + +.module-quote__icon-container__icon { + width: 24px; + height: 24px; +} + +.module-quote__icon-container__icon--file { + @include color-svg('../images/file.svg', $color-signal-blue); +} +.module-quote__icon-container__icon--image { + @include color-svg('../images/image.svg', $color-signal-blue); +} +.module-quote__icon-container__icon--microphone { + @include color-svg('../images/microphone.svg', $color-signal-blue); +} +.module-quote__icon-container__icon--play { + @include color-svg('../images/play.svg', $color-signal-blue); +} +.module-quote__icon-container__icon--movie { + @include color-svg('../images/movie.svg', $color-signal-blue); +} + +.module-quote__generic-file { + display: flex; + flex-direction: row; + align-items: center; +} +.module-quote__generic-file__icon { + background: url('../images/file-gradient.svg'); + background-size: 75%; + background-repeat: no-repeat; + height: 28px; + width: 36px; + margin-left: -4px; + margin-right: -6px; + margin-bottom: 5px; +} +.module-quote__generic-file__text { + font-size: 14px; + line-height: 18px; + color: $color-light-90; +} + +// Module: Embedded Contact + +.module-embedded-contact { + // Cursor is always a pointer because this component is always wired up to the contact detail screen + cursor: pointer; + display: flex; + flex-direction: row; + align-items: stretch; +} + +.module-embedded-contact--with-content-above { + padding-top: 4px; +} + +.module-embedded-contact--with-content-below { + padding-bottom: 4px; +} + +.module-embedded-contact__image-container { + flex: initial; + min-width: 50px; + width: 50px; + height: 50px; + + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + object-fit: cover; + + img { + border-radius: 50%; + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.module-embedded-contact__image-container__default-avatar { + border-radius: 50%; + width: 100%; + height: 100%; + background-color: gray; + color: white; + font-size: 25px; + line-height: 52px; +} + +.module-embedded-contact__text-container { + flex-grow: 1; + margin-left: 8px; + + max-width: calc(100% - 58px); +} + +.module-embedded-contact__contact-name { + font-size: 14px; + line-height: 18px; + font-weight: 300; + margin-top: 6px; + color: $color-light-90; + + max-width: 100%; + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; +} + +.module-embedded-contact__contact-name--incoming { + color: $color-white; +} + +.module-embedded-contact__contact-method { + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + margin-top: 3px; + color: $color-light-60; + + max-width: 100%; + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; +} + +.module-embedded-contact__contact-method--incoming { + color: $color-white-07; +} + +// Module: Contact Detail + +.module-contact-detail { + text-align: center; + max-width: 300px; + margin-left: auto; + margin-right: auto; +} + +.module-contact-detail__image-container { + height: 80px; + width: 80px; + margin-bottom: 4px; + + text-align: center; + display: inline-block; + object-fit: cover; + + img { + border-radius: 50%; + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.module-contact-detail__image-container__default-avatar { + border-radius: 50%; + width: 100%; + height: 100%; + background-color: gray; + color: white; + font-size: 50px; + line-height: 82px; +} + +.module-contact-detail__contact-name { + font-size: 20px; + font-weight: bold; +} + +.module-contact-detail__contact-method { + font-size: 14px; + margin-top: 10px; +} + +.module-contact-detail__send-message { + cursor: pointer; + + border-radius: 4px; + background-color: $blue; + display: inline-block; + padding: 6px; + margin-top: 20px; + + color: white; + + flex-direction: column; + align-items: center; + + button { + @include button-reset; + } +} + +.module-contact-detail__send-message__inner { + display: flex; + align-items: center; +} + +.module-contact-detail__send-message__bubble-icon { + height: 17px; + width: 18px; + display: inline-block; + margin-right: 5px; + @include color-svg('../images/chat-bubble.svg', white); +} + +.module-contact-detail__additional-contact { + text-align: left; + border-top: 1px solid $grey_l1_5; + margin-top: 15px; + padding-top: 8px; +} + +.module-contact-detail__additional-contact__type { + color: rgba(0, 0, 0, 0.5); + font-size: 12px; + margin-bottom: 3px; +} + +// Module: Notification + +.module-notification { + font-size: 13px; + line-height: 18px; + color: $color-light-60; + text-align: center; +} + +.module-notification--with-click-handler { + cursor: pointer; +} + +.module-notification__icon { + height: 1.25em; + width: 1.25em; + vertical-align: text-bottom; + display: inline-block; +} + +.module-notification__icon--verified { + @include color-svg('../images/verified-check.svg', $color-light-60); +} + +.module-notification__icon--shield { + @include color-svg('../images/shield.svg', $color-light-60); +} + +.module-notification__icon--clock { + @include color-svg('../images/clock.svg', $color-light-60); +} + +// Module: Contact List Item + +.module-contact-list-item { + display: flex; + flex-direction: row; + align-items: center; + + color: $color-light-60; +} + +.module-contact-list-item--with-click-handler { + cursor: pointer; +} + +.module-contact-list-item__avatar { + display: inline-block; + + img { + height: 44px; + width: 44px; + border-radius: 22px; + } +} + +.module-contact-list-item__avatar-default { + height: 44px; + width: 44px; + border-radius: 22px; + + display: flex; + flex-direction: row; + align-items: center; + + text-align: center; + background-color: $color-conversation-grey; +} + +.module-contact-list-item__avatar-default--grey { + background-color: $color-conversation-grey; +} +.module-contact-list-item__avatar-default--blue { + background-color: $color-conversation-blue; +} +.module-contact-list-item__avatar-default--cyan { + background-color: $color-conversation-cyan; +} +.module-contact-list-item__avatar-default--deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-contact-list-item__avatar-default--green { + background-color: $color-conversation-green; +} +.module-contact-list-item__avatar-default--indigo { + background-color: $color-conversation-indigo; +} +.module-contact-list-item__avatar-default--pink { + background-color: $color-conversation-pink; +} +.module-contact-list-item__avatar-default--purple { + background-color: $color-conversation-purple; +} +.module-contact-list-item__avatar-default--red { + background-color: $color-conversation-red; +} +.module-contact-list-item__avatar-default--teal { + background-color: $color-conversation-teal; +} + +.module-contact-list-item__avatar-default__label { + width: 100%; + color: $color-white; + font-size: 18px; +} + +.module-contact-list-item__text { + margin-left: 8px; +} + +.module-contact-list-item__text__name { + font-weight: 300; + font-size: 14px; +} + +.module-contact-list-item__text__profile-name { + font-size: 12px; + font-weight: normal; +} + +.module-contact-list-item__text__additional-data { + margin-top: 3px; + font-size: 14px; +} + +.module-contact-list-item__text__verified-icon { + @include color-svg('../images/verified-check.svg', $color-light-60); + display: inline-block; + width: 18px; + height: 18px; + vertical-align: text-bottom; + + // Trying to account for the whitespace around the check mark + margin-bottom: -1px; +} diff --git a/stylesheets/options.scss b/stylesheets/_options.scss similarity index 100% rename from stylesheets/options.scss rename to stylesheets/_options.scss diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 0674c7fca..41e7cddb8 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -19,5 +19,8 @@ @import 'theme_light'; @import 'theme_dark'; +// New CSS +@import 'modules'; + // Installer @import 'options'; diff --git a/ts/components/ContactListItem.md b/ts/components/ContactListItem.md new file mode 100644 index 000000000..1e94d8ee4 --- /dev/null +++ b/ts/components/ContactListItem.md @@ -0,0 +1,98 @@ +#### It's me! + +```jsx + console.log('onClick')} +/> +``` + +#### With name and profile + +```jsx + console.log('onClick')} +/> +``` + +#### With name and profile, verified + +```jsx + console.log('onClick')} +/> +``` + +#### With name and profile, no avatar + +```jsx + console.log('onClick')} +/> +``` + +#### Profile, no name, no avatar + +```jsx + console.log('onClick')} +/> +``` + +#### Verified, profile, no name, no avatar + +```jsx + console.log('onClick')} +/> +``` + +#### No name, no profile, no avatar + +```jsx + console.log('onClick')} +/> +``` + +#### Verified, no name, no profile, no avatar + +```jsx + console.log('onClick')} +/> +``` diff --git a/ts/components/ContactListItem.tsx b/ts/components/ContactListItem.tsx new file mode 100644 index 000000000..faec61877 --- /dev/null +++ b/ts/components/ContactListItem.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import classnames from 'classnames'; + +import { Emojify } from './conversation/Emojify'; + +import { Localizer } from '../types/Util'; + +interface Props { + phoneNumber: string; + isMe?: boolean; + name?: string; + color?: string; + verified: boolean; + profileName?: string; + avatarPath?: string; + i18n: Localizer; + onClick?: () => void; +} + +function getInitial(name: string): string { + return name.trim()[0] || '#'; +} + +export class ContactListItem extends React.Component { + public renderAvatar({ displayName }: { displayName: string }) { + const { avatarPath, i18n, color, name } = this.props; + + if (avatarPath) { + return ( +
+ {i18n('contactAvatarAlt', +
+ ); + } + + const title = name ? getInitial(name) : '#'; + + return ( +
+
+ {title} +
+
+ ); + } + + public render() { + const { + i18n, + name, + onClick, + isMe, + phoneNumber, + profileName, + verified, + } = this.props; + + const title = name ? name : phoneNumber; + const displayName = isMe ? i18n('me') : title; + + const profileElement = + !isMe && profileName && !name ? ( + + ~ + + ) : null; + + const showNumber = isMe || name; + const showVerified = !isMe && verified; + + return ( +
+ {this.renderAvatar({ displayName })} +
+
+ {profileElement} +
+
+ {showVerified ? ( +
+ ) : null} + {showVerified ? ` ${i18n('verified')}` : null} + {showVerified && showNumber ? ' ∙ ' : null} + {showNumber ? phoneNumber : null} +
+
+
+ ); + } +} diff --git a/ts/components/conversation/AddNewLines.tsx b/ts/components/conversation/AddNewLines.tsx index 12996d059..a1562fad4 100644 --- a/ts/components/conversation/AddNewLines.tsx +++ b/ts/components/conversation/AddNewLines.tsx @@ -10,7 +10,7 @@ interface Props { export class AddNewLines extends React.Component { public static defaultProps: Partial = { - renderNonNewLine: ({ text, key }) => {text}, + renderNonNewLine: ({ text }) => text, }; public render() { diff --git a/ts/components/conversation/ContactDetail.tsx b/ts/components/conversation/ContactDetail.tsx index 26a7dade0..08173f6d0 100644 --- a/ts/components/conversation/ContactDetail.tsx +++ b/ts/components/conversation/ContactDetail.tsx @@ -14,7 +14,6 @@ import { renderAvatar, renderContactShorthand, renderName, - renderSendMessage, } from './EmbeddedContact'; import { Localizer } from '../../types/Util'; @@ -70,6 +69,40 @@ function getLabelForAddress(address: PostalAddress, i18n: Localizer): string { } export class ContactDetail extends React.Component { + public renderSendMessage({ + hasSignalAccount, + i18n, + onSendMessage, + }: { + hasSignalAccount: boolean; + i18n: (key: string, values?: Array) => string; + onSendMessage: () => void; + }) { + if (!hasSignalAccount) { + return null; + } + + // We don't want the overall click handler for this element to fire, so we stop + // propagation before handing control to the caller's callback. + const onClick = (e: React.MouseEvent<{}>): void => { + e.stopPropagation(); + onSendMessage(); + }; + + return ( +
+ +
+ ); + } + public renderEmail(items: Array | undefined, i18n: Localizer) { if (!items || items.length === 0) { return; @@ -77,8 +110,13 @@ export class ContactDetail extends React.Component { return items.map((item: Email) => { return ( -
-
{getLabelForEmail(item, i18n)}
+
+
+ {getLabelForEmail(item, i18n)} +
{item.value}
); @@ -92,8 +130,13 @@ export class ContactDetail extends React.Component { return items.map((item: Phone) => { return ( -
-
{getLabelForPhone(item, i18n)}
+
+
+ {getLabelForPhone(item, i18n)} +
{item.value}
); @@ -142,8 +185,10 @@ export class ContactDetail extends React.Component { return addresses.map((address: PostalAddress, index: number) => { return ( -
-
{getLabelForAddress(address, i18n)}
+
+
+ {getLabelForAddress(address, i18n)} +
{this.renderAddressLine(address.street)} {this.renderPOBox(address.pobox, i18n)} {this.renderAddressLine(address.neighborhood)} @@ -156,13 +201,15 @@ export class ContactDetail extends React.Component { public render() { const { contact, hasSignalAccount, i18n, onSendMessage } = this.props; + const isIncoming = false; + const module = 'contact-detail'; return ( -
- {renderAvatar(contact, i18n)} - {renderName(contact)} - {renderContactShorthand(contact)} - {renderSendMessage({ hasSignalAccount, i18n, onSendMessage })} +
+ {renderAvatar({ contact, i18n, module })} + {renderName({ contact, isIncoming, module })} + {renderContactShorthand({ contact, isIncoming, module })} + {this.renderSendMessage({ hasSignalAccount, i18n, onSendMessage })} {this.renderPhone(contact.number, i18n)} {this.renderEmail(contact.email, i18n)} {this.renderAddresses(contact.address, i18n)} diff --git a/ts/components/conversation/ConversationTitle.md b/ts/components/conversation/ConversationTitle.md index 85f095fab..109f2f5fc 100644 --- a/ts/components/conversation/ConversationTitle.md +++ b/ts/components/conversation/ConversationTitle.md @@ -6,7 +6,7 @@ i18n={util.i18n} isVerified name="Someone 🔥 Somewhere" - phoneNumber="+12025550011" + phoneNumber="(202) 555-0011" profileName="🔥Flames🔥" />
@@ -19,7 +19,7 @@
``` @@ -30,7 +30,7 @@
@@ -40,6 +40,6 @@ ```jsx
- +
``` diff --git a/ts/components/conversation/EmbeddedContact.md b/ts/components/conversation/EmbeddedContact.md index a92b3c2ce..641a4c145 100644 --- a/ts/components/conversation/EmbeddedContact.md +++ b/ts/components/conversation/EmbeddedContact.md @@ -3,280 +3,516 @@ #### Including all data types ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [ - { - name: { - displayName: 'Someone Somewhere', +const contacts = [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: util.CONTACTS[0].id, - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + }, +]; - - + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> +; +``` + +#### Really long long data + +``` +const contacts = [ + { + name: { + displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips', + }, + number: [ + { + value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000', + type: 1, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + }, +]; + + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> ; ``` #### In group conversation ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [ - { - name: { - displayName: 'Someone Somewhere', +const contacts = [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: util.CONTACTS[0].id, - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + }, +]; - - + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> ; ``` #### If contact has no signal account ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [ - { - name: { - displayName: 'Someone Somewhere', +const contacts = [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: '+12025551000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + }, +]; - - + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> ; ``` #### With organization name instead of name ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [ - { - organization: 'United Somewheres, Inc.', - email: [ - { - value: 'someone@somewheres.com', - type: 2, - }, - ], +const contacts = [ + { + organization: 'United Somewheres, Inc.', + email: [ + { + value: 'someone@somewheres.com', + type: 2, + }, + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + }, +]; - - + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + + console.log('onClickContact')} + /> ; ``` #### No displayName or organization ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [ - { - name: { - givenName: 'Someone', +const contacts = [ + { + name: { + givenName: 'Someone', + }, + number: [ + { + value: '+12025551000', + type: 1, }, - number: [ - { - value: '+12025551000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + }, +]; - - + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> ; ``` #### Default avatar ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - value: util.CONTACTS[0].id, - type: 1, - }, - ], +const contacts = [ + { + name: { + displayName: 'Someone Somewhere', }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + number: [ + { + value: util.CONTACTS[0].id, + type: 1, + }, + ], + }, +]; - - + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> ; ``` #### Empty contact ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - contact: [{}], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; +const contacts = [{}]; - - + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> ; ``` #### Contact with caption (cannot currently be sent) ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - body: 'I want to introduce you to Someone...', - contact: [ - { - name: { - displayName: 'Someone Somewhere', +const contacts = [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: util.CONTACTS[0].id, - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - }) -); -const View = Whisper.MessageView; + }, +]; - - + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + contactHasSignalAccount + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + contactHasSignalAccount + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + /> + console.log('onClickContact')} + contactHasSignalAccount + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> + console.log('onClickContact')} + contactHasSignalAccount + onSendMessageToContact={() => console.log('onSendMessageToContact')} + /> ; ``` diff --git a/ts/components/conversation/EmbeddedContact.tsx b/ts/components/conversation/EmbeddedContact.tsx index d6d788c7d..a54d9fa2a 100644 --- a/ts/components/conversation/EmbeddedContact.tsx +++ b/ts/components/conversation/EmbeddedContact.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import classnames from 'classnames'; + import { Contact, getName } from '../../types/Contact'; import { Localizer } from '../../types/Util'; @@ -7,30 +9,44 @@ interface Props { contact: Contact; hasSignalAccount: boolean; i18n: Localizer; - onSendMessage: () => void; - onOpenContact: () => void; + isIncoming: boolean; + withContentAbove: boolean; + withContentBelow: boolean; + onSendMessage?: () => void; + onClickContact?: () => void; } export class EmbeddedContact extends React.Component { public render() { const { contact, - hasSignalAccount, i18n, - onOpenContact, - onSendMessage, + isIncoming, + onClickContact, + withContentAbove, + withContentBelow, } = this.props; + const module = 'embedded-contact'; return ( -
-
- {renderAvatar(contact, i18n)} -
- {renderName(contact)} - {renderContactShorthand(contact)} -
+
+ {renderAvatar({ contact, i18n, module })} +
+ {renderName({ contact, isIncoming, module })} + {renderContactShorthand({ contact, isIncoming, module })}
- {renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
); } @@ -38,68 +54,85 @@ export class EmbeddedContact extends React.Component { // Note: putting these below the main component so style guide picks up EmbeddedContact -function getInitials(name: string): string { +function getInitial(name: string): string { return name.trim()[0] || '#'; } -export function renderAvatar(contact: Contact, i18n: Localizer) { +export function renderAvatar({ + contact, + i18n, + module, +}: { + contact: Contact; + i18n: Localizer; + module: string; +}) { const { avatar } = contact; const path = avatar && avatar.avatar && avatar.avatar.path; + const name = getName(contact) || ''; + if (!path) { - const name = getName(contact); - const initials = getInitials(name || ''); + const initials = getInitial(name); return ( -
-
{initials}
+
+
+ {initials} +
); } return ( -
- {i18n('contactAvatarAlt')} +
+ {i18n('contactAvatarAlt',
); } -export function renderName(contact: Contact) { - return
{getName(contact)}
; +export function renderName({ + contact, + isIncoming, + module, +}: { + contact: Contact; + isIncoming: boolean; + module: string; +}) { + return ( +
+ {getName(contact)} +
+ ); } -export function renderContactShorthand(contact: Contact) { +export function renderContactShorthand({ + contact, + isIncoming, + module, +}: { + contact: Contact; + isIncoming: boolean; + module: string; +}) { const { number: phoneNumber, email } = contact; const firstNumber = phoneNumber && phoneNumber[0] && phoneNumber[0].value; const firstEmail = email && email[0] && email[0].value; - return
{firstNumber || firstEmail}
; -} - -export function renderSendMessage(props: { - hasSignalAccount: boolean; - i18n: (key: string, values?: Array) => string; - onSendMessage: () => void; -}) { - const { hasSignalAccount, i18n, onSendMessage } = props; - - if (!hasSignalAccount) { - return null; - } - - // We don't want the overall click handler for this element to fire, so we stop - // propagation before handing control to the caller's callback. - const onClick = (e: React.MouseEvent<{}>): void => { - e.stopPropagation(); - onSendMessage(); - }; - return ( -
- +
+ {firstNumber || firstEmail}
); } diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index 841be0a64..fa8158db5 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -56,7 +56,7 @@ interface Props { export class Emojify extends React.Component { public static defaultProps: Partial = { - renderNonEmoji: ({ text, key }) => {text}, + renderNonEmoji: ({ text }) => text, }; public render() { diff --git a/ts/components/conversation/Linkify.tsx b/ts/components/conversation/Linkify.tsx index 41fa6293f..0bc4af763 100644 --- a/ts/components/conversation/Linkify.tsx +++ b/ts/components/conversation/Linkify.tsx @@ -16,7 +16,7 @@ const SUPPORTED_PROTOCOLS = /^(http|https):/i; export class Linkify extends React.Component { public static defaultProps: Partial = { - renderNonLink: ({ text, key }) => {text}, + renderNonLink: ({ text }) => text, }; public render() { diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index 1b01c3cba..dfd98ef76 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -1,53 +1,294 @@ -Placeholder component: +### Plain messages + +Note that timestamp and status can be hidden with the `collapseMetadata` boolean property. ```jsx - + + + + + + + + + + ``` -## MessageView (Backbone) - -### Plain messages +### Timestamps ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'How are you doing this fine day?', - sent_at: Date.now() - 18000, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; +function get1201() { + const d = new Date(); + d.setHours(0, 0, 1, 0); + return d.getTime(); +} +function getYesterday1159() { + return get1201() - 2 * 60 * 1000; +} +function getJanuary1201() { + const now = new Date(); + const d = new Date(now.getFullYear(), 0, 1, 0, 1); + return d.getTime(); +} +function getDecember1159() { + return getJanuary1201() - 2 * 60 * 1000; +} + - - + + + + + + + + + + + + + + + + ; ``` -### In a group conversation +### Status ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'How are you doing this fine day?', - sent_at: Date.now() - 200000, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - - -; + + + + + + + + + + ``` ### With an error @@ -114,202 +355,123 @@ const View = Whisper.MessageView; ; ``` -#### No message contents +### Disappearing messages ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); - -const View = Whisper.MessageView; - - -; -``` - -### Disappearing - -```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - body: 'This message will self-destruct in two minutes', -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); - -const View = Whisper.MessageView; - - - -; -``` - -### Notfications - -#### Timer change - -```jsx -const fromOther = new Whisper.Message({ - type: 'incoming', - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - source: '+12025550003', - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - expirationTimerUpdate: { - source: '+12025550003', - }, -}); -const fromUpdate = new Whisper.Message({ - type: 'incoming', - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - source: util.ourNumber, - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - expirationTimerUpdate: { - fromSync: true, - source: util.ourNumber, - }, -}); -const fromMe = new Whisper.Message({ - type: 'incoming', - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - source: util.ourNumber, - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - expirationTimerUpdate: { - source: util.ourNumber, - }, -}); -const View = Whisper.ExpirationTimerUpdateView; - - - - -; -``` - -#### Safety number change - -```js -const incoming = new Whisper.Message({ - type: 'keychange', - sent_at: Date.now() - 200000, - key_changed: '+12025550003', -}); -const View = Whisper.KeyChangeView; - - -; -``` - -#### Marking as verified - -```js -const fromPrimary = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', - verified: true, -}); -const local = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', - local: true, - verified: true, -}); - -const View = Whisper.VerifiedChangeView; - - - -; -``` - -#### Marking as not verified - -```js -const fromPrimary = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', -}); -const local = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', - local: true, -}); - -const View = Whisper.VerifiedChangeView; - - - -; -``` - -#### Group update - -```js -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - group_update: { - joined: ['+12025550007', '+12025550008', '+12025550009'], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); - -const View = Whisper.MessageView; - - - -; -``` - -#### End session - -```js -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); - -const View = Whisper.MessageView; - - - -; + + + + + + + + + + + + + + + ``` ### With an attachment @@ -317,357 +479,806 @@ const View = Whisper.MessageView; #### Image with caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'I am pretty confused about Pi.', - sent_at: Date.now() - 18000000, - attachments: [ - { - data: util.gif, - fileName: 'pi.gif', - contentType: 'image/gif', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Image +First, showing the metadata overlay on dark and light images, then a message with `collapseMetadata` set. + ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - attachments: [ - { - data: util.gif, - fileName: 'pi.gif', - contentType: 'image/gif', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + +``` + +#### Outgoing image with status + +Note that the delivered indicator is always Signal Blue, not the conversation color. + +```jsx + + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Image with portrait aspect ratio ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - attachments: [ - { - data: util.portraitYellow, - fileName: 'portraitYellow.png', - contentType: 'image/png', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Image with portrait aspect ratio and caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'This is an odd yellow bar. Cool, huh?', - sent_at: Date.now() - 18000000, - attachments: [ - { - data: util.portraitYellow, - fileName: 'portraitYellow.png', - contentType: 'image/png', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Image with landscape aspect ratio ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - attachments: [ - { - data: util.landscapePurple, - fileName: 'landscapePurple.jpg', - contentType: 'image/jpeg', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Image with landscape aspect ratio and caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: "An interesting horizontal bar. It's art.", - sent_at: Date.now() - 18000000, - attachments: [ - { - data: util.landscapePurple, - fileName: 'landscapePurple.jpg', - contentType: 'image/jpeg', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Video with caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: "Beautiful, isn't it?", - sent_at: Date.now() - 10000, - attachments: [ - { - data: util.mp4, - fileName: 'freezing_bubble.mp4', - contentType: 'video/mp4', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Video +We don't currently overlay message metadata on top of videos like we do with images. + ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 10000, - attachments: [ - { - data: util.mp4, - fileName: 'freezing_bubble.mp4', - contentType: 'video/mp4', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Audio with caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'This is a nice song', - sent_at: Date.now() - 15000, - attachments: [ - { - data: util.mp3, - fileName: 'agnus_dei.mp3', - contentType: 'audio/mp3', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Audio ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 15000, - attachments: [ - { - data: util.mp3, - fileName: 'agnus_dei.mp3', - contentType: 'audio/mp3', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + ``` #### Voice message -```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 15000, - attachments: [ - { - flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE, - data: util.mp3, - fileName: 'agnus_dei.mp3', - contentType: 'audio/mp3', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - - -; -``` +Voice notes are not shown any differently from audio attachments. #### Other file type with caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'My manifesto is now complete!', - sent_at: Date.now() - 15000, - attachments: [ - { - data: util.txt, - fileName: 'lorum_ipsum.txt', - contentType: 'text/plain', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + + ``` #### Other file type ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 15000, - attachments: [ - { - data: util.txt, - fileName: 'lorum_ipsum.txt', - contentType: 'text/plain', - }, - ], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + console.log('onClickAttachment')} + /> + +``` + +### In a group conversation + +Note that the author avatar goes away if `collapseMetadata` is set. + +```jsx + + + + + + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> + + + + + ``` diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index e272c3409..6a0eadd82 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1,39 +1,579 @@ -// tslint:disable:newline-before-return - import React from 'react'; +import classnames from 'classnames'; +import moment from 'moment'; +import { padStart } from 'lodash'; + +import { formatRelativeTime } from '../../util/formatRelativeTime'; + +import { MessageBody } from './MessageBody'; +import { Emojify } from './Emojify'; +import { Quote, QuotedAttachment } from './Quote'; +import { EmbeddedContact } from './EmbeddedContact'; + +import { Contact } from '../../types/Contact'; +import { Localizer } from '../../types/Util'; +import * as MIME from '../../../ts/types/MIME'; + +interface Attachment { + contentType: MIME.MIMEType; + fileName: string; + /** Not included in protobuf, needs to be pulled from flags */ + isVoiceMessage: boolean; + /** For messages not already on disk, this will be a data url */ + url: string; + fileSize?: string; +} + +interface Props { + text?: string; + id?: string; + collapseMetadata?: boolean; + direction: 'incoming' | 'outgoing'; + timestamp: number; + status?: 'sending' | 'sent' | 'delivered' | 'read'; + contacts?: Array; + color: + | 'gray' + | 'blue' + | 'cyan' + | 'deep-orange' + | 'green' + | 'indigo' + | 'pink' + | 'purple' + | 'red' + | 'teal'; + i18n: Localizer; + authorName?: string; + authorProfileName?: string; + /** Note: this should be formatted for display */ + authorPhoneNumber?: string; + conversationType: 'group' | 'direct'; + attachment?: Attachment; + quote?: { + text: string; + attachments: Array; + isFromMe: boolean; + authorName?: string; + authorPhoneNumber?: string; + authorProfileName?: string; + }; + authorAvatarPath?: string; + contactHasSignalAccount: boolean; + expirationLength?: number; + expirationTimestamp?: number; + onClickQuote?: () => void; + onSendMessageToContact?: () => void; + onClickContact?: () => void; + onClickAttachment?: () => void; +} + +function isImage(attachment?: Attachment) { + // TODO: exclude svg and tiff here + return ( + attachment && attachment.contentType && MIME.isImage(attachment.contentType) + ); +} + +function isVideo(attachment?: Attachment) { + return ( + attachment && attachment.contentType && MIME.isVideo(attachment.contentType) + ); +} + +function isAudio(attachment?: Attachment) { + return ( + attachment && attachment.contentType && MIME.isAudio(attachment.contentType) + ); +} + +function getTimerBucket(expiration: number, length: number): string { + const delta = expiration - Date.now(); + if (delta < 0) { + return '00'; + } + if (delta > length) { + return '60'; + } + + const increment = Math.round(delta / length * 12); + + return padStart(String(increment * 5), 2, '0'); +} + +function getExtension({ + fileName, + contentType, +}: { + fileName: string; + contentType: MIME.MIMEType; +}): string | null { + if (fileName && fileName.indexOf('.') >= 0) { + const lastPeriod = fileName.lastIndexOf('.'); + const extension = fileName.slice(lastPeriod + 1); + if (extension.length) { + return extension; + } + } + + const slash = contentType.indexOf('/'); + if (slash >= 0) { + return contentType.slice(slash + 1); + } + + return null; +} + +export class Message extends React.Component { + public renderTimer() { + const { + attachment, + direction, + expirationLength, + expirationTimestamp, + text, + } = this.props; + + if (!expirationLength || !expirationTimestamp) { + return null; + } + + const withImageNoCaption = !text && isImage(attachment); + const bucket = getTimerBucket(expirationTimestamp, expirationLength); -/** - * A placeholder Message component for now, giving the structure of a plain message with - * none of the dynamic functionality. This page will be used to build up our corpus of - * permutations before we start moving all message functionality to React. - */ -export class Message extends React.Component { - public render() { return ( -
  • - -
    -
    -
    -
    -

    - - Hi there. How are you doing? Feeling pretty good? Awesome. - -

    +
    + ); + } + + public renderMetadata() { + const { + collapseMetadata, + color, + direction, + i18n, + status, + timestamp, + text, + attachment, + } = this.props; + + if (collapseMetadata) { + return null; + } + // We're not showing metadata on top of videos since they still have native controls + if (!text && isVideo(attachment)) { + return null; + } + + const withImageNoCaption = !text && isImage(attachment); + + return ( +
    + + {formatRelativeTime(timestamp, { i18n, extended: true })} + + {this.renderTimer()} + + {direction === 'outgoing' ? ( +
    + ) : null} +
    + ); + } + + public renderAuthor() { + const { + authorName, + conversationType, + direction, + i18n, + authorPhoneNumber, + authorProfileName, + } = this.props; + + const title = authorName ? authorName : authorPhoneNumber; + + if (direction !== 'incoming' || conversationType !== 'group' || !title) { + return null; + } + + const profileElement = + authorProfileName && !authorName ? ( + + ~ + + ) : null; + + return ( +
    + {profileElement} +
    + ); + } + + public renderAttachment() { + const { + i18n, + attachment, + text, + collapseMetadata, + conversationType, + direction, + quote, + onClickAttachment, + } = this.props; + + if (!attachment) { + return null; + } + + const withCaption = Boolean(text); + // For attachments which aren't full-frame + const withContentBelow = withCaption || !collapseMetadata; + const withContentAbove = + quote || (conversationType === 'group' && direction === 'incoming'); + + if (isImage(attachment)) { + return ( +
    + {i18n('imageAttachmentAlt')} + {!withCaption && !collapseMetadata ? ( +
    + ) : null} +
    + ); + } else if (isVideo(attachment)) { + return ( + + ); + } else if (isAudio(attachment)) { + return ( + + ); + } else { + const { fileName, fileSize, contentType } = attachment; + const extension = getExtension({ contentType, fileName }); + + return ( +
    +
    + {extension ? ( +
    + {extension} +
    + ) : null} +
    +
    +
    + {fileName} +
    +
    + {fileSize}
    -
    - - 1 minute ago - - - -
    +
    + ); + } + } + + public renderQuote() { + const { + color, + conversationType, + direction, + i18n, + onClickQuote, + quote, + } = this.props; + + if (!quote) { + return null; + } + + const authorTitle = quote.authorName + ? quote.authorName + : quote.authorPhoneNumber; + const authorProfileName = !quote.authorName + ? quote.authorProfileName + : undefined; + const withContentAbove = + conversationType === 'group' && direction === 'incoming'; + + return ( + + ); + } + + public renderEmbeddedContact() { + const { + collapseMetadata, + contactHasSignalAccount, + contacts, + conversationType, + direction, + i18n, + onClickContact, + onSendMessageToContact, + text, + } = this.props; + const first = contacts && contacts[0]; + + if (!first) { + return null; + } + + const withCaption = Boolean(text); + const withContentAbove = + conversationType === 'group' && direction === 'incoming'; + const withContentBelow = withCaption || !collapseMetadata; + + return ( + + ); + } + + public renderSendMessageButton() { + const { + contactHasSignalAccount, + contacts, + i18n, + onSendMessageToContact, + } = this.props; + const first = contacts && contacts[0]; + + if (!first || !contactHasSignalAccount) { + return null; + } + + return ( +
    + {i18n('sendMessageToContact')} +
    + ); + } + + public renderAvatar() { + const { + authorName, + authorPhoneNumber, + authorProfileName, + authorAvatarPath, + collapseMetadata, + color, + conversationType, + direction, + i18n, + } = this.props; + + const title = `${authorName || authorPhoneNumber}${ + !authorName && authorProfileName ? ` ~${authorProfileName}` : '' + }`; + + if ( + collapseMetadata || + conversationType !== 'group' || + direction === 'outgoing' + ) { + return; + } + + if (!authorAvatarPath) { + return ( +
    +
    #
    +
    + ); + } + + return ( +
    + {i18n('contactAvatarAlt', +
    + ); + } + + public renderText() { + const { text, i18n, direction } = this.props; + + if (!text) { + return null; + } + + return ( +
    + +
    + ); + } + + public render() { + const { + attachment, + color, + conversationType, + direction, + id, + quote, + text, + } = this.props; + + const imageAndNothingElse = + !text && isImage(attachment) && conversationType !== 'group' && !quote; + + return ( +
  • +
    + {this.renderAuthor()} + {this.renderQuote()} + {this.renderAttachment()} + {this.renderEmbeddedContact()} + {this.renderText()} + {this.renderMetadata()} + {this.renderSendMessageButton()} + {this.renderAvatar()}
  • ); diff --git a/ts/components/conversation/Notification.md b/ts/components/conversation/Notification.md new file mode 100644 index 000000000..3ec261087 --- /dev/null +++ b/ts/components/conversation/Notification.md @@ -0,0 +1,151 @@ +### Timer change + +```jsx +const fromOther = new Whisper.Message({ + type: 'incoming', + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + source: '+12025550003', + sent_at: Date.now() - 200000, + expireTimer: 120, + expirationStartTimestamp: Date.now() - 1000, + expirationTimerUpdate: { + source: '+12025550003', + }, +}); +const fromUpdate = new Whisper.Message({ + type: 'incoming', + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + source: util.ourNumber, + sent_at: Date.now() - 200000, + expireTimer: 120, + expirationStartTimestamp: Date.now() - 1000, + expirationTimerUpdate: { + fromSync: true, + source: util.ourNumber, + }, +}); +const fromMe = new Whisper.Message({ + type: 'incoming', + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + source: util.ourNumber, + sent_at: Date.now() - 200000, + expireTimer: 120, + expirationStartTimestamp: Date.now() - 1000, + expirationTimerUpdate: { + source: util.ourNumber, + }, +}); +const View = Whisper.ExpirationTimerUpdateView; + + + + + console.log('onClick')} /> +; +``` + +### Safety number change + +```js +const incoming = new Whisper.Message({ + type: 'keychange', + sent_at: Date.now() - 200000, + key_changed: '+12025550003', +}); +const View = Whisper.KeyChangeView; + + +; +``` + +### Marking as verified + +```js +const fromPrimary = new Whisper.Message({ + type: 'verified-change', + sent_at: Date.now() - 200000, + verifiedChanged: '+12025550003', + verified: true, +}); +const local = new Whisper.Message({ + type: 'verified-change', + sent_at: Date.now() - 200000, + verifiedChanged: '+12025550003', + local: true, + verified: true, +}); + +const View = Whisper.VerifiedChangeView; + + + +; +``` + +### Marking as not verified + +```js +const fromPrimary = new Whisper.Message({ + type: 'verified-change', + sent_at: Date.now() - 200000, + verifiedChanged: '+12025550003', +}); +const local = new Whisper.Message({ + type: 'verified-change', + sent_at: Date.now() - 200000, + verifiedChanged: '+12025550003', + local: true, +}); + +const View = Whisper.VerifiedChangeView; + + + +; +``` + +### Group update + +```js +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 200000, + group_update: { + joined: ['+12025550007', '+12025550008', '+12025550009'], + }, +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', + }) +); + +const View = Whisper.MessageView; + + + +; +``` + +### End session + +```js +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 200000, + flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', + }) +); + +const View = Whisper.MessageView; + + + +; +``` diff --git a/ts/components/conversation/Notification.tsx b/ts/components/conversation/Notification.tsx new file mode 100644 index 000000000..eeaa3d7ec --- /dev/null +++ b/ts/components/conversation/Notification.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classnames from 'classnames'; + +interface Props { + type: string; + onClick: () => void; +} + +export class Notification extends React.Component { + public renderContents() { + const { type } = this.props; + + return Notification of type {type}; + } + + public render() { + const { onClick } = this.props; + + return ( +
    + {this.renderContents()} +
    + ); + } +} diff --git a/ts/components/conversation/Quote.md b/ts/components/conversation/Quote.md index 5ab2f5896..b3308a05e 100644 --- a/ts/components/conversation/Quote.md +++ b/ts/components/conversation/Quote.md @@ -3,560 +3,679 @@ #### Plain text ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'About six', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### With emoji ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'About 🔥six🔥', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many 🔥ferrets🔥 do you have? ', - author: '+12025550011', - id: Date.now() - 1000, - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Replies to you or yourself ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'About six', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: util.ourNumber, - id: Date.now() - 1000, - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: util.ourNumber, - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### In a group conversation ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'About six', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550010', - id: Date.now() - 1000, - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550007', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550002', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + authorAvatarPath={util.gifObjectUrl} + /> + console.log('onClickQuote')} + authorAvatarPath={util.gifObjectUrl} + /> + ``` #### A lot of text in quotation ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Woo, otters!', - sent_at: Date.now() - 18000000, - quote: { - text: - 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + - 'After that, probably dogs. And then, you know, reptiles of all types. ' + - 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + - 'really smart.', - author: '+12025550011', - id: Date.now() - 1000, - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### A lot of text in quotation, with icon ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Woo, otters!', - sent_at: Date.now() - 18000000, - quote: { - text: - 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + - 'After that, probably dogs. And then, you know, reptiles of all types. ' + - 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + - 'really smart.', - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'text/plain', - fileName: 'lorum_ipsum.txt', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### A lot of text in quotation, with image ```jsx -const thumbnail = { - objectUrl: util.gifObjectUrl, -}; -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Woo, otters!', - sent_at: Date.now() - 18000000, - quote: { - text: - 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + - 'After that, probably dogs. And then, you know, reptiles of all types. ' + - 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + - 'really smart.', - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'image/gif', - fileName: 'pi.gif', - thumbnail: { - contentType: 'image/gif', - }, - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -outgoing.quoteThumbnail = thumbnail; -incoming.quoteThumbnail = thumbnail; - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Image with caption ```jsx -const thumbnail = { - objectUrl: util.gifObjectUrl, - id: '3234-23423-2342', -}; -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: "Totally, it's a pretty unintuitive concept.", - sent_at: Date.now() - 18000000, - quote: { - text: 'I am pretty confused about Pi.', - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'image/gif', - fileName: 'pi.gif', - thumbnail: { - contentType: 'image/gif', - }, - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -outgoing.quoteThumbnail = thumbnail; -incoming.quoteThumbnail = thumbnail; - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Image ```jsx -const thumbnail = { - objectUrl: util.gifObjectUrl, -}; - -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Yeah, pi. Tough to wrap your head around.', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'image/gif', - fileName: 'pi.gif', - thumbnail: { - contentType: 'image/gif', - }, - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -outgoing.quoteThumbnail = thumbnail; -incoming.quoteThumbnail = thumbnail; - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Image with no thumbnail ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Yeah, pi. Tough to wrap your head around.', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'image/gif', - fileName: 'pi.gif', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Video with caption ```jsx -const thumbnail = { - objectUrl: util.gifObjectUrl, -}; - -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Sweet the way the video sneaks up on you!', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - text: 'Check out this video I found!', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'video/mp4', - fileName: 'freezing_bubble.mp4', - thumbnail: { - contentType: 'image/gif', - }, - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -outgoing.quoteThumbnail = thumbnail; -incoming.quoteThumbnail = thumbnail; - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Video ```jsx -const thumbnail = { - objectUrl: util.gifObjectUrl, -}; - -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Awesome!', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'video/mp4', - fileName: 'freezing_bubble.mp4', - thumbnail: { - contentType: 'image/gif', - data: util.gif, - }, - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -outgoing.quoteThumbnail = thumbnail; -incoming.quoteThumbnail = thumbnail; - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Video with no thumbnail ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Awesome!', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'video/mp4', - fileName: 'freezing_bubble.mp4', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); - -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Audio with caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'I really like it!', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - text: 'Check out this beautiful song!', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'audio/mp3', - fileName: 'agnus_dei.mp4', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Audio ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'I really like it!', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'audio/mp3', - fileName: 'agnus_dei.mp4', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Voice message @@ -571,8 +690,6 @@ const outgoing = new Whisper.Message({ id: Date.now() - 1000, attachments: [ { - // proposed as of afternoon of 4/6 in Quoted Replies group - flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE, contentType: 'audio/mp3', fileName: 'agnus_dei.mp4', }, @@ -590,78 +707,132 @@ const incoming = new Whisper.Message( ); const View = Whisper.MessageView; - - + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> ; ``` #### Other file type with caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: "I can't read latin.", - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - text: 'This is my manifesto. Tell me what you think!', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'text/plain', - fileName: 'lorum_ipsum.txt', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` #### Other file type ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: "Sorry, I can't read latin!", - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'text/plain', - fileName: 'lorum_ipsum.txt', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - -; + console.log('onClickQuote')} + /> + console.log('onClickQuote')} + /> + ``` ### With a quotation, including attachment @@ -669,240 +840,269 @@ const View = Whisper.MessageView; #### Quote, image attachment, and caption ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'Like pi or so?', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, - attachments: [ - { - data: util.gif, + + - - -; + }} + onClickQuote={() => console.log('onClickQuote')} + text="About six" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + console.log('onClickQuote')} + color="green" + text="About six" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + ``` #### Quote, image attachment ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, - attachments: [ - { - data: util.gif, + + - - -; + }} + onClickQuote={() => console.log('onClickQuote')} + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + console.log('onClickQuote')} + color="green" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + ``` #### Quote, portrait image attachment ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, - attachments: [ - { - data: util.portraitYellow, + + - - -; + }} + onClickQuote={() => console.log('onClickQuote')} + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + console.log('onClickQuote')} + color="green" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + ``` #### Quote, video attachment ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, - attachments: [ - { - data: util.mp4, + + - - -; + }} + onClickQuote={() => console.log('onClickQuote')} + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + console.log('onClickQuote')} + color="green" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + ``` #### Quote, audio attachment ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, - attachments: [ - { - data: util.mp3, + + - - -; + }} + onClickQuote={() => console.log('onClickQuote')} + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + console.log('onClickQuote')} + color="green" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + ``` #### Quote, file attachment ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, - attachments: [ - { - data: util.txt, + + - - -; -``` - -#### Quote, but no message - -```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 18000000, - quote: { - text: 'How many ferrets do you have?', - author: '+12025550011', - id: Date.now() - 1000, - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - - - -; + fileSize: '3.05 KB', + }} + onClickQuote={() => console.log('onClickQuote')} + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + console.log('onClickQuote')} + color="green" + i18n={util.i18n} + quote={{ + text: 'How many ferrets do you have?', + attachments: [], + authorPhoneNumber: '(202) 555-0011', + }} + onClickQuote={() => console.log('onClickQuote')} + /> + ``` ### In bottom bar diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index bb86cf9c4..368efe6c6 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -1,7 +1,7 @@ // tslint:disable:react-this-binding-issue import React from 'react'; -import classNames from 'classnames'; +import classnames from 'classnames'; import * as MIME from '../../../ts/types/MIME'; import * as GoogleChrome from '../../../ts/util/GoogleChrome'; @@ -12,18 +12,19 @@ import { Localizer } from '../../types/Util'; interface Props { attachments: Array; - authorColor: string; + color: string; authorProfileName?: string; authorTitle: string; i18n: Localizer; - isFromMe: string; + isFromMe: boolean; isIncoming: boolean; + withContentAbove: boolean; onClick?: () => void; onClose?: () => void; text: string; } -interface QuotedAttachment { +export interface QuotedAttachment { contentType: MIME.MIMEType; fileName: string; /** Not included in protobuf */ @@ -57,32 +58,93 @@ function getObjectUrl(thumbnail: Attachment | undefined): string | null { return null; } +function getTypeLabel({ + i18n, + contentType, + isVoiceMessage, +}: { + i18n: Localizer; + contentType: MIME.MIMEType; + isVoiceMessage: boolean; +}): string | null { + if (GoogleChrome.isVideoTypeSupported(contentType)) { + return i18n('video'); + } + if (GoogleChrome.isImageTypeSupported(contentType)) { + return i18n('photo'); + } + if (MIME.isAudio(contentType) && isVoiceMessage) { + return i18n('voiceMessage'); + } + if (MIME.isAudio(contentType)) { + return i18n('audio'); + } + + return null; +} + export class Quote extends React.Component { public renderImage(url: string, i18n: Localizer, icon?: string) { const iconElement = icon ? ( -
    +
    +
    +
    +
    +
    ) : null; return ( -
    -
    - {i18n('quoteThumbnailAlt')} - {iconElement} -
    +
    + {i18n('quoteThumbnailAlt')} + {iconElement}
    ); } public renderIcon(icon: string) { - const { authorColor, isIncoming } = this.props; + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } - const backgroundColor = isIncoming ? 'white' : authorColor; - const iconColor = isIncoming ? authorColor : 'white'; + public renderGenericFile() { + const { attachments } = this.props; + + if (!attachments || !attachments.length) { + return; + } + + const first = attachments[0]; + const { fileName, contentType } = first; + const isGenericFile = + !GoogleChrome.isVideoTypeSupported(contentType) && + !GoogleChrome.isImageTypeSupported(contentType) && + !MIME.isAudio(contentType); + + if (!isGenericFile) { + return null; + } return ( -
    -
    -
    +
    +
    +
    {fileName}
    ); } @@ -111,7 +173,7 @@ export class Quote extends React.Component { return this.renderIcon('microphone'); } - return this.renderIcon('file'); + return null; } public renderText() { @@ -119,7 +181,7 @@ export class Quote extends React.Component { if (text) { return ( -
    +
    ); @@ -130,43 +192,16 @@ export class Quote extends React.Component { } const first = attachments[0]; - const { contentType, fileName, isVoiceMessage } = first; + const { contentType, isVoiceMessage } = first; - if (GoogleChrome.isVideoTypeSupported(contentType)) { - return
    {i18n('video')}
    ; - } - if (GoogleChrome.isImageTypeSupported(contentType)) { - return
    {i18n('photo')}
    ; - } - if (MIME.isAudio(contentType) && isVoiceMessage) { - return
    {i18n('voiceMessage')}
    ; - } - if (MIME.isAudio(contentType)) { - return
    {i18n('audio')}
    ; + const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage }); + if (typeLabel) { + return ( +
    {typeLabel}
    + ); } - return
    {fileName}
    ; - } - - public renderIOSLabel() { - const { - i18n, - isIncoming, - isFromMe, - authorTitle, - authorProfileName, - } = this.props; - - const profileString = authorProfileName ? ` ~${authorProfileName}` : ''; - const authorName = `${authorTitle}${profileString}`; - - const label = isFromMe - ? isIncoming - ? i18n('replyingToYou') - : i18n('replyingToYourself') - : i18n('replyingTo', [authorName]); - - return
    {label}
    ; + return null; } public renderClose() { @@ -185,29 +220,27 @@ export class Quote extends React.Component { // We need the container to give us the flexibility to implement the iOS design. return ( -
    -
    +
    +
    ); } public renderAuthor() { - const { - authorColor, - authorProfileName, - authorTitle, - i18n, - isFromMe, - } = this.props; + const { authorProfileName, authorTitle, i18n, isFromMe } = this.props; const authorProfileElement = authorProfileName ? ( - + ~ ) : null; return ( -
    +
    {isFromMe ? ( i18n('you') ) : ( @@ -220,24 +253,27 @@ export class Quote extends React.Component { } public render() { - const { authorColor, onClick, isFromMe } = this.props; + const { color, isIncoming, onClick, withContentAbove } = this.props; if (!validateQuote(this.props)) { return null; } - const classes = classNames( - authorColor, - 'quoted-message', - isFromMe ? 'from-me' : null, - !onClick ? 'no-click' : null - ); - return ( -
    -
    - {this.renderIOSLabel()} +
    +
    {this.renderAuthor()} + {this.renderGenericFile()} {this.renderText()}
    {this.renderIconContainer()} diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index da98bb5b4..912862661 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -35,6 +35,9 @@ const txtObjectUrl = makeObjectUrl(txt, 'text/plain'); // @ts-ignore import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4'; const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4'); +// @ts-ignore +import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png'; +const pngObjectUrl = makeObjectUrl(png, 'image/png'); // @ts-ignore import landscapeGreen from '../../fixtures/1000x50-green.jpeg'; @@ -70,6 +73,8 @@ export { gifObjectUrl, mp4, mp4ObjectUrl, + png, + pngObjectUrl, txt, txtObjectUrl, landscapeGreen, diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 9819b5a85..abe06251b 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -3,6 +3,6 @@ export type RenderTextCallback = ( text: string; key: number; } -) => JSX.Element; +) => JSX.Element | string; export type Localizer = (key: string, values?: Array) => string; diff --git a/ts/util/formatRelativeTime.ts b/ts/util/formatRelativeTime.ts new file mode 100644 index 000000000..7da339ccb --- /dev/null +++ b/ts/util/formatRelativeTime.ts @@ -0,0 +1,53 @@ +import moment from 'moment'; +import { Localizer } from '../types/Util'; + +const getExtendedFormats = (i18n: Localizer) => ({ + y: 'lll', + M: `${i18n('timestampFormat_M') || 'MMM D'} LT`, + d: 'ddd LT', +}); +const getShortFormats = (i18n: Localizer) => ({ + y: 'll', + M: i18n('timestampFormat_M') || 'MMM D', + d: 'ddd', +}); + +function isToday(timestamp: moment.Moment) { + const today = moment().format('ddd'); + const targetDay = moment(timestamp).format('ddd'); + + return today === targetDay; +} + +function isYear(timestamp: moment.Moment) { + const year = moment().format('YYYY'); + const targetYear = moment(timestamp).format('YYYY'); + + return year === targetYear; +} + +export function formatRelativeTime( + rawTimestamp: number | Date, + options: { extended: boolean; i18n: Localizer } +) { + const { extended, i18n } = options; + + const formats = extended ? getExtendedFormats(i18n) : getShortFormats(i18n); + const timestamp = moment(rawTimestamp); + const now = moment(); + const diff = moment.duration(now.diff(timestamp)); + + if (diff.years() >= 1 || !isYear(timestamp)) { + return timestamp.format(formats.y); + } else if (diff.months() >= 1 || diff.days() > 6) { + return timestamp.format(formats.M); + } else if (diff.days() >= 1 || !isToday(timestamp)) { + return timestamp.format(formats.d); + } else if (diff.hours() >= 1) { + return i18n('hoursAgo', [String(diff.hours())]); + } else if (diff.minutes() >= 1) { + return i18n('minutesAgo', [String(diff.minutes())]); + } + + return i18n('justNow'); +} diff --git a/ts/util/migrateColor.ts b/ts/util/migrateColor.ts new file mode 100644 index 000000000..689a0caa8 --- /dev/null +++ b/ts/util/migrateColor.ts @@ -0,0 +1,70 @@ +// import { missingCaseError } from './missingCaseError'; + +type OldColor = + | 'amber' + | 'blue' + | 'blue_grey' + | 'cyan' + | 'deep_orange' + | 'deep_purple' + | 'green' + | 'grey' + | 'indigo' + | 'light_blue' + | 'light_green' + | 'orange' + | 'pink' + | 'purple' + | 'red' + | 'teal'; + +type NewColor = + | 'blue' + | 'cyan' + | 'deep_orange' + | 'grey' + | 'green' + | 'indigo' + | 'pink' + | 'purple' + | 'red' + | 'teal'; + +export function migrateColor(color: OldColor): NewColor { + switch (color) { + // These colors no longer exist + case 'amber': + case 'orange': + return 'red'; + + case 'blue_grey': + case 'light_blue': + return 'blue'; + + case 'deep_purple': + return 'purple'; + + case 'light_green': + return 'teal'; + + // These can stay as they are + case 'blue': + case 'cyan': + case 'deep_orange': + case 'green': + case 'grey': + case 'indigo': + case 'pink': + case 'purple': + case 'red': + case 'teal': + return color; + + // Can uncomment this to ensure that we've covered all potential cases + // default: + // throw missingCaseError(color); + + default: + return 'grey'; + } +}