New React component: Message

Also: Use react to render contects on the 'show group members' screen
This commit is contained in:
Scott Nonnenberg 2018-06-27 13:53:49 -07:00
parent 3ea14ce450
commit dc11db92f9
48 changed files with 5299 additions and 2093 deletions

View File

@ -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

View File

@ -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',

View File

@ -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.",

View File

@ -1 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z" /></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>check</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Android-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Android-Light/Message/Status/Sent" transform="translate(-6.000000, 0.000000)" fill="#000000" fill-rule="nonzero">
<path d="M12,0 C15.312,0 18,2.688 18,6 C18,9.312 15.312,12 12,12 C8.688,12 6,9.312 6,6 C6,2.688 8.688,0 12,0 Z M12,1 C9.24,1 7,3.24 7,6 C7,8.76 9.24,11 12,11 C14.76,11 17,8.76 17,6 C17,3.24 14.76,1 12,1 Z M11,8.5 L8.5,6 L9.205,5.295 L11,7.085 L14.795,3.29 L15.5,4 L11,8.5 Z" id="check"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 901 B

View File

@ -1 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M36 14l-2.83-2.83-12.68 12.69 2.83 2.83L36 14zm8.49-2.83L23.31 32.34 14.97 24l-2.83 2.83L23.31 38l24-24-2.82-2.83zM.83 26.83L12 38l2.83-2.83L3.66 24 .83 26.83z"/></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="12px" viewBox="0 0 18 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>double check</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M7.91731278,0.313257194 C7.58941091,0.549084144 7.28273546,0.812570593 7.00070199,1.10030099 C6.67734551,1.03453102 6.34268082,1 6,1 C3.24,1 1,3.24 1,6 C1,8.76 3.24,11 6,11 C6.34268082,11 6.67734551,10.965469 7.00070199,10.899699 C7.28273546,11.1874294 7.58941091,11.4509159 7.91731278,11.6867428 C7.31518343,11.8898758 6.67037399,12 6,12 C2.688,12 0,9.312 0,6 C0,2.688 2.688,0 6,0 C6.67037399,0 7.31518343,0.110124239 7.91731278,0.313257194 Z M5.07266453,7.01233547 C5.12977459,7.4065842 5.21974274,7.79019382 5.33970233,8.16029767 L5,8.5 L2.5,6 L3.205,5.295 L5,7.085 L5.07266453,7.01233547 Z M12,0 C15.312,0 18,2.688 18,6 C18,9.312 15.312,12 12,12 C8.688,12 6,9.312 6,6 C6,2.688 8.688,0 12,0 Z M12,1 C9.24,1 7,3.24 7,6 C7,8.76 9.24,11 12,11 C14.76,11 17,8.76 17,6 C17,3.24 14.76,1 12,1 Z M11,8.5 L8.5,6 L9.205,5.295 L11,7.085 L14.795,3.29 L15.5,4 L11,8.5 Z" id="path-1"></path>
</defs>
<g id="Android-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Android-Light/Message/Status/Delivered">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="double-check" fill="#000000" fill-rule="nonzero" xlink:href="#path-1"></use>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 1.6 KiB

51
images/file-gradient.svg Normal file
View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="44px" height="56px" viewBox="0 0 44 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>File</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M27.4611409,10.6408637 C27.795393,10.9983288 28,11.4785445 28,12.0065487 C28,12.0141465 28,20.3474799 28,37.0065487 C28,38.663403 26.6568542,40.0065487 25,40.0065487 L3,40.0065487 C1.34314575,40.0065487 2.02906125e-16,38.663403 0,37.0065487 L0,3.00654871 C-2.02906125e-16,1.34969446 1.34314575,0.00654871324 3,0.00654871324 L16.0265395,0 C16.5865346,0.0158576852 17.0851294,0.249509571 17.4418757,0.620545804 C17.7827796,0.975104947 27.0961475,10.2505223 27.4611409,10.6408637 Z" id="path-1"></path>
<filter x="-50.0%" y="-30.0%" width="200.0%" height="170.0%" filterUnits="objectBoundingBox" id="filter-3">
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="4" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feMorphology radius="0.5" operator="dilate" in="SourceAlpha" result="shadowSpreadOuter2"></feMorphology>
<feOffset dx="0" dy="0" in="shadowSpreadOuter2" result="shadowOffsetOuter2"></feOffset>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" type="matrix" in="shadowOffsetOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
<path d="M16,0.00654871324 L28,0.00654871324 L28,12.0065487 C28,10.9019792 27.1045695,10.0065487 26,10.0065487 L21,10.0065487 C19.3431458,10.0065487 18,8.66340296 18,7.00654871 L18,2.00654871 C18,0.901979214 17.1045695,0.00654871324 16,0.00654871324 Z" id="path-4"></path>
<filter x="-58.3%" y="-41.7%" width="216.7%" height="216.7%" filterUnits="objectBoundingBox" id="filter-5">
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="2" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
</filter>
<filter x="-37.5%" y="-20.8%" width="175.0%" height="175.0%" filterUnits="objectBoundingBox" id="filter-6">
<feMorphology radius="1" operator="erode" in="SourceAlpha" result="shadowSpreadInner1"></feMorphology>
<feOffset dx="0" dy="0" in="shadowSpreadInner1" result="shadowOffsetInner1"></feOffset>
<feComposite in="shadowOffsetInner1" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="shadowInnerInner1"></feComposite>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" type="matrix" in="shadowInnerInner1"></feColorMatrix>
</filter>
</defs>
<g id="Desktop-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Desktop-Light/Primitives/File" transform="translate(8.000000, 6.000000)">
<g id="File">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Combined-Shape">
<use fill="black" fill-opacity="1" filter="url(#filter-3)" xlink:href="#path-1"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
</g>
<g id="Combined-Shape" mask="url(#mask-2)">
<use fill="black" fill-opacity="1" filter="url(#filter-5)" xlink:href="#path-4"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-4"></use>
<use fill="black" fill-opacity="1" filter="url(#filter-6)" xlink:href="#path-4"></use>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

12
images/sending.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>sending</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="iOS-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="iOS-Light/Message/Status/Sending" transform="translate(-6.000000, 0.000000)" fill="#000000">
<path d="M11.5,0 L12.5,0 L12.5,1 L11.5,1 L11.5,0 Z M11.5,11 L12.5,11 L12.5,12 L11.5,12 L11.5,11 Z M6,5.5 L7,5.5 L7,6.5 L6,6.5 L6,5.5 Z M17,5.5 L18,5.5 L18,6.5 L17,6.5 L17,5.5 Z M16.9461524,2.5669873 L17.4461524,3.4330127 L16.580127,3.9330127 L16.080127,3.0669873 L16.9461524,2.5669873 Z M7.41987298,8.0669873 L7.91987298,8.9330127 L7.05384758,9.4330127 L6.55384758,8.5669873 L7.41987298,8.0669873 Z M9.4330127,0.553847577 L9.9330127,1.41987298 L9.0669873,1.91987298 L8.5669873,1.05384758 L9.4330127,0.553847577 Z M14.9330127,10.080127 L15.4330127,10.9461524 L14.5669873,11.4461524 L14.0669873,10.580127 L14.9330127,10.080127 Z M14.5669873,0.553847577 L15.4330127,1.05384758 L14.9330127,1.91987298 L14.0669873,1.41987298 L14.5669873,0.553847577 Z M9.0669873,10.080127 L9.9330127,10.580127 L9.4330127,11.4461524 L8.5669873,10.9461524 L9.0669873,10.080127 Z M7.05384758,2.5669873 L7.91987298,3.0669873 L7.41987298,3.9330127 L6.55384758,3.4330127 L7.05384758,2.5669873 Z M16.580127,8.0669873 L17.4461524,8.5669873 L16.9461524,9.4330127 L16.080127,8.9330127 L16.580127,8.0669873 Z" id="sending"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

10
images/timer-00.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 00/timer00_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-00/timer00_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M11.428367,3.44328115 L10.5587469,3.94535651 C10.4906607,3.79477198 10.4145019,3.64614153 10.330127,3.5 C10.2457522,3.35385847 10.1551138,3.21358774 10.0587469,3.07933111 L10.928367,2.57725574 C11.0225793,2.71323387 11.1119641,2.85418158 11.1961524,3 C11.2803407,3.14581842 11.3577126,3.2937018 11.428367,3.44328115 Z M9.42274426,1.07163304 L8.92066889,1.94125309 C8.78641226,1.84488615 8.64614153,1.75424783 8.5,1.66987298 C8.35385847,1.58549813 8.20522802,1.50933927 8.05464349,1.44125309 L8.55671885,0.571633044 C8.7062982,0.642287382 8.85418158,0.719659271 9,0.803847577 C9.14581842,0.888035884 9.28676613,0.977420696 9.42274426,1.07163304 Z M11.9794631,6.5 L10.9753124,6.5 C10.9916403,6.33554688 11,6.1687497 11,6 C11,5.8312503 10.9916403,5.66445312 10.9753124,5.5 L11.9794631,5.5 C11.9930643,5.66486669 12,5.83162339 12,6 C12,6.16837661 11.9930643,6.33513331 11.9794631,6.5 Z M10.928367,9.42274426 L10.0587469,8.92066889 C10.1551138,8.78641226 10.2457522,8.64614153 10.330127,8.5 C10.4145019,8.35385847 10.4906607,8.20522802 10.5587469,8.05464349 L11.428367,8.55671885 C11.3577126,8.7062982 11.2803407,8.85418158 11.1961524,9 C11.1119641,9.14581842 11.0225793,9.28676613 10.928367,9.42274426 Z M8.55671885,11.428367 L8.05464349,10.5587469 C8.20522802,10.4906607 8.35385847,10.4145019 8.5,10.330127 C8.64614153,10.2457522 8.78641226,10.1551138 8.92066889,10.0587469 L9.42274426,10.928367 C9.28676613,11.0225793 9.14581842,11.1119641 9,11.1961524 C8.85418158,11.2803407 8.7062982,11.3577126 8.55671885,11.428367 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M5.5,11.9794631 L5.5,10.9753124 C5.66445312,10.9916403 5.8312503,11 6,11 C6.1687497,11 6.33554688,10.9916403 6.5,10.9753124 L6.5,11.9794631 C6.33513331,11.9930643 6.16837661,12 6,12 C5.83162339,12 5.66486669,11.9930643 5.5,11.9794631 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z M6.5,0.0205368885 L6.5,7 L5.5,7 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,5.01e-14 6,5.01e-14 C6.16837661,5.01e-14 6.33513331,0.00693566443 6.5,0.0205368885 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

10
images/timer-05.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 05/timer05_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-05/timer05_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,1.02370353 L6.5,1.02468762 C6.33554688,1.00835972 6.1687497,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,4.42354486e-17 6,4.42354486e-17 L5.87059234,-7.37983893e-14 C7.16354589,-0.0289161279 8.40910326,0.364429763 9.43644004,1.08141215 L8.9316493,1.94944159 L8.92055711,1.94144648 L6.43301236,6.25000036 L5.56698696,5.75000036 L8.0545317,1.44144648 C7.56661533,1.2212014 7.04204534,1.07826289 6.5,1.02370353 Z M11.428367,3.44328115 L10.5587469,3.94535651 C10.4906607,3.79477198 10.4145019,3.64614153 10.330127,3.5 C10.2457522,3.35385847 10.1551138,3.21358774 10.0587469,3.07933111 L10.928367,2.57725574 C11.0225793,2.71323387 11.1119641,2.85418158 11.1961524,3 C11.2803407,3.14581842 11.3577126,3.2937018 11.428367,3.44328115 Z M11.9794631,6.5 L10.9753124,6.5 C10.9916403,6.33554688 11,6.1687497 11,6 C11,5.8312503 10.9916403,5.66445312 10.9753124,5.5 L11.9794631,5.5 C11.9930643,5.66486669 12,5.83162339 12,6 C12,6.16837661 11.9930643,6.33513331 11.9794631,6.5 Z M10.928367,9.42274426 L10.0587469,8.92066889 C10.1551138,8.78641226 10.2457522,8.64614153 10.330127,8.5 C10.4145019,8.35385847 10.4906607,8.20522802 10.5587469,8.05464349 L11.428367,8.55671885 C11.3577126,8.7062982 11.2803407,8.85418158 11.1961524,9 C11.1119641,9.14581842 11.0225793,9.28676613 10.928367,9.42274426 Z M8.55671885,11.428367 L8.05464349,10.5587469 C8.20522802,10.4906607 8.35385847,10.4145019 8.5,10.330127 C8.64614153,10.2457522 8.78641226,10.1551138 8.92066889,10.0587469 L9.42274426,10.928367 C9.28676613,11.0225793 9.14581842,11.1119641 9,11.1961524 C8.85418158,11.2803407 8.7062982,11.3577126 8.55671885,11.428367 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M5.5,11.9794631 L5.5,10.9753124 C5.66445312,10.9916403 5.8312503,11 6,11 C6.1687497,11 6.33554688,10.9916403 6.5,10.9753124 L6.5,11.9794631 C6.33513331,11.9930643 6.16837661,12 6,12 C5.83162339,12 5.66486669,11.9930643 5.5,11.9794631 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

10
images/timer-10.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 10/timer10_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-10/timer10_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,1.02430376 L6.5,1.02468762 C6.33554688,1.00835972 6.1687497,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,1.62630326e-17 6,1.62630326e-17 L5.86308464,3.75931924e-14 C6.41989049,-0.0124561971 6.98779297,0.0530213558 7.5529141,0.204445108 C9.33301785,0.681422471 10.710634,1.91072271 11.4353383,3.45859809 L10.564162,3.95793826 L10.5585534,3.9454682 L6.24999954,6.43301294 L5.74999954,5.56698753 L10.0585534,3.07944279 C9.4085445,2.17504687 8.45381951,1.48111816 7.29409506,1.17037093 C7.02944452,1.09945804 6.764062,1.05116398 6.5,1.02430376 Z M11.9794631,6.5 L10.9753124,6.5 C10.9916403,6.33554688 11,6.1687497 11,6 C11,5.8312503 10.9916403,5.66445312 10.9753124,5.5 L11.9794631,5.5 C11.9930643,5.66486669 12,5.83162339 12,6 C12,6.16837661 11.9930643,6.33513331 11.9794631,6.5 Z M10.928367,9.42274426 L10.0587469,8.92066889 C10.1551138,8.78641226 10.2457522,8.64614153 10.330127,8.5 C10.4145019,8.35385847 10.4906607,8.20522802 10.5587469,8.05464349 L11.428367,8.55671885 C11.3577126,8.7062982 11.2803407,8.85418158 11.1961524,9 C11.1119641,9.14581842 11.0225793,9.28676613 10.928367,9.42274426 Z M8.55671885,11.428367 L8.05464349,10.5587469 C8.20522802,10.4906607 8.35385847,10.4145019 8.5,10.330127 C8.64614153,10.2457522 8.78641226,10.1551138 8.92066889,10.0587469 L9.42274426,10.928367 C9.28676613,11.0225793 9.14581842,11.1119641 9,11.1961524 C8.85418158,11.2803407 8.7062982,11.3577126 8.55671885,11.428367 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M5.5,11.9794631 L5.5,10.9753124 C5.66445312,10.9916403 5.8312503,11 6,11 C6.1687497,11 6.33554688,10.9916403 6.5,10.9753124 L6.5,11.9794631 C6.33513331,11.9930643 6.16837661,12 6,12 C5.83162339,12 5.66486669,11.9930643 5.5,11.9794631 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

10
images/timer-15.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 15/timer15_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-15/timer15_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,0.0207420606 C7.86488275,0.134195272 9.19834885,0.713067711 10.2426405,1.75735938 C11.5457669,3.06048577 12.1241674,4.8138991 11.977842,6.51675063 L10.9737111,6.51360374 L10.975089,6.50000007 L5.9999995,6.50000007 L5.9999995,5.50000007 L10.975089,5.50000007 C10.8643627,4.39176576 10.384511,3.31344338 9.53553374,2.46446616 C8.55922305,1.48815547 7.27961153,1.00000011 6,1.00000007 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,0 6,0 C6.16837661,0 6.33513331,0.00693566443 6.5,0.0205368885 L6.5,0.0207420606 Z M10.928367,9.42274426 L10.0587469,8.92066889 C10.1551138,8.78641226 10.2457522,8.64614153 10.330127,8.5 C10.4145019,8.35385847 10.4906607,8.20522802 10.5587469,8.05464349 L11.428367,8.55671885 C11.3577126,8.7062982 11.2803407,8.85418158 11.1961524,9 C11.1119641,9.14581842 11.0225793,9.28676613 10.928367,9.42274426 Z M8.55671885,11.428367 L8.05464349,10.5587469 C8.20522802,10.4906607 8.35385847,10.4145019 8.5,10.330127 C8.64614153,10.2457522 8.78641226,10.1551138 8.92066889,10.0587469 L9.42274426,10.928367 C9.28676613,11.0225793 9.14581842,11.1119641 9,11.1961524 C8.85418158,11.2803407 8.7062982,11.3577126 8.55671885,11.428367 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M5.5,11.9794631 L5.5,10.9753124 C5.66445312,10.9916403 5.8312503,11 6,11 C6.1687497,11 6.33554688,10.9916403 6.5,10.9753124 L6.5,11.9794631 C6.33513331,11.9930643 6.16837661,12 6,12 C5.83162339,12 5.66486669,11.9930643 5.5,11.9794631 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -1.13006288e-12,6.16837661 -1.13009381e-12,6 C-1.13012474e-12,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

10
images/timer-20.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 20/timer20_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-20/timer20_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,1.02370353 L6.5,1.02468762 C6.33554688,1.00835972 6.1687497,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,-1.1492543e-17 6,-1.1492543e-17 L5.87059232,4.19252306e-14 C8.57132191,-0.0604002043 11.0652567,1.72157607 11.7955548,4.4470858 C12.2725322,6.22718955 11.896735,8.03489028 10.9185877,9.43644027 L10.0505583,8.93164953 L10.0585534,8.92055734 L5.74999954,6.4330126 L6.24999954,5.5669872 L10.5585534,8.05453194 C11.0167788,7.03940974 11.1403762,5.86562929 10.829629,4.70590484 C10.2763071,2.64087962 8.50807111,1.22582513 6.5,1.02370353 Z M8.55671885,11.428367 L8.05464349,10.5587469 C8.20522802,10.4906607 8.35385847,10.4145019 8.5,10.330127 C8.64614153,10.2457522 8.78641226,10.1551138 8.92066889,10.0587469 L9.42274426,10.928367 C9.28676613,11.0225793 9.14581842,11.1119641 9,11.1961524 C8.85418158,11.2803407 8.7062982,11.3577126 8.55671885,11.428367 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M5.5,11.9794631 L5.5,10.9753124 C5.66445312,10.9916403 5.8312503,11 6,11 C6.1687497,11 6.33554688,10.9916403 6.5,10.9753124 L6.5,11.9794631 C6.33513331,11.9930643 6.16837661,12 6,12 C5.83162339,12 5.66486669,11.9930643 5.5,11.9794631 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

10
images/timer-25.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 25/timer25_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-25/timer25_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,1.02430376 L6.5,1.02468762 C6.33554688,1.00835972 6.1687497,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,4.81385765e-17 6,4.81385765e-17 L5.86308468,4.16843209e-14 C6.41989052,-0.012456193 6.98779298,0.0530213603 7.5529141,0.204445109 C10.7537107,1.06209598 12.6532057,4.35211772 11.7955548,7.55291434 C11.3185774,9.33301809 10.0892772,10.7106343 8.54140181,11.4353385 L8.04206164,10.5641622 L8.0545317,10.5585537 L5.56698696,6.24999978 L6.43301237,5.74999978 L8.92055711,10.0585537 C9.82495303,9.40854474 10.5188817,8.45381974 10.829629,7.29409529 C11.544338,4.62676478 9.96142558,1.88507999 7.29409506,1.17037094 C7.02944452,1.09945804 6.764062,1.05116398 6.5,1.02430376 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M5.5,11.9794631 L5.5,10.9753124 C5.66445312,10.9916403 5.8312503,11 6,11 C6.1687497,11 6.33554688,10.9916403 6.5,10.9753124 L6.5,11.9794631 C6.33513331,11.9930643 6.16837661,12 6,12 C5.83162339,12 5.66486669,11.9930643 5.5,11.9794631 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

10
images/timer-30.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 30/timer30_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-30/timer30_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,0.0207420321 C7.86488252,0.134195172 9.19834876,0.713067621 10.2426405,1.75735938 C12.5857863,4.10050513 12.5857863,7.899495 10.2426405,10.2426408 C8.93951413,11.5457671 7.1861008,12.1241676 5.48324927,11.9778423 L5.48639616,10.9737113 L5.49999983,10.9750892 L5.49999983,5.99999973 L6.49999983,5.99999973 L6.49999983,10.9750892 C7.60823414,10.8643629 8.68655652,10.3845112 9.53553374,9.53553397 C11.4881552,7.58291251 11.4881552,4.41708762 9.53553374,2.46446616 C8.60186739,1.53079981 7.39081733,1.04357578 6.16765141,1.00279407 C6.11199785,1.00092414 6.05610685,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,0 6,0 C6.16837661,0 6.33513331,0.00693566443 6.5,0.0205368885 L6.5,0.0207420321 Z M2.57725574,10.928367 L3.07933111,10.0587469 C3.21358774,10.1551138 3.35385847,10.2457522 3.5,10.330127 C3.64614153,10.4145019 3.79477198,10.4906607 3.94535651,10.5587469 L3.44328115,11.428367 C3.2937018,11.3577126 3.14581842,11.2803407 3,11.1961524 C2.85418158,11.1119641 2.71323387,11.0225793 2.57725574,10.928367 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

10
images/timer-35.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 35/timer35_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-35/timer35_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,1.02370354 L6.5,1.02468762 C6.33554688,1.00835972 6.1687497,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,4.09828421e-17 6,4.09828421e-17 L5.87059244,4.2745538e-14 C8.57132199,-0.0604001451 11.0652567,1.72157611 11.7955548,4.4470858 C12.6532057,7.64788242 10.7537107,10.9379042 7.5529141,11.795555 C5.77281035,12.2725324 3.96510962,11.8967353 2.56355963,10.918588 L3.06835037,10.0505585 L3.07944256,10.0585537 L5.5669873,5.74999978 L6.4330127,6.24999978 L3.94546796,10.5585537 C4.96059016,11.016779 6.13437061,11.1403764 7.29409506,10.8296292 C9.96142558,10.1149201 11.544338,7.37323536 10.829629,4.70590484 C10.2763071,2.64087963 8.50807111,1.22582514 6.5,1.02370354 Z M0.571633044,8.55671885 L1.44125309,8.05464349 C1.50933927,8.20522802 1.58549813,8.35385847 1.66987298,8.5 C1.75424783,8.64614153 1.84488615,8.78641226 1.94125309,8.92066889 L1.07163304,9.42274426 C0.977420696,9.28676613 0.888035884,9.14581842 0.803847577,9 C0.719659271,8.85418158 0.642287382,8.7062982 0.571633044,8.55671885 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

10
images/timer-40.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 40/timer40_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-40/timer40_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,1.02430376 L6.5,1.02468762 C6.33554688,1.00835972 6.1687497,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,-9.97465999e-18 6,-9.97465999e-18 L5.86308481,-6.71970472e-11 C6.4198906,-0.0124561805 6.98779302,0.0530213744 7.5529141,0.204445112 C10.7537107,1.06209598 12.6532057,4.35211772 11.7955548,7.55291434 C10.9379039,10.753711 7.64788218,12.6532059 4.44708556,11.795555 C2.66698181,11.3185777 1.28936562,10.0892774 0.564661357,8.54140205 L1.43583768,8.04206188 L1.44144625,8.05453194 L5.75000012,5.5669872 L6.25000012,6.4330126 L1.94144625,8.92055734 C2.59145516,9.82495327 3.54618016,10.518882 4.70590461,10.8296292 C7.37323512,11.5443383 10.1149199,9.96142581 10.829629,7.29409529 C11.544338,4.62676478 9.96142558,1.88508 7.29409506,1.17037094 C7.02944452,1.09945804 6.764062,1.05116398 6.5,1.02430376 Z M0.0205368885,5.5 L1.02468762,5.5 C1.00835972,5.66445312 1,5.8312503 1,6 C1,6.1687497 1.00835972,6.33554688 1.02468762,6.5 L0.0205368885,6.5 C0.00693566443,6.33513331 -9.95062878e-13,6.16837661 -9.95093808e-13,6 C-9.95124738e-13,5.83162339 0.00693566443,5.66486669 0.0205368885,5.5 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

10
images/timer-45.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 45/timer45_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-45/timer45_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.5,0.0207420343 C7.86488252,0.134195174 9.19834876,0.713067624 10.2426405,1.75735938 C12.5857863,4.10050513 12.5857863,7.89949501 10.2426405,10.2426408 C7.89949477,12.5857865 4.1005049,12.5857865 1.75735914,10.2426408 C0.454232755,8.93951437 -0.124167745,7.18610104 0.0221576434,5.48324951 L1.02628856,5.4863964 L1.02491069,5.50000007 L6.00000017,5.50000007 L6.00000017,6.50000007 L1.02491069,6.50000007 C1.13563696,7.60823437 1.61548871,8.68655676 2.46446593,9.53553398 C4.41708738,11.4881554 7.58291228,11.4881554 9.53553374,9.53553398 C11.4881552,7.58291252 11.4881552,4.41708762 9.53553374,2.46446616 C8.60186704,1.53079946 7.39081643,1.04357542 6.16765005,1.00279403 C6.11199694,1.00092412 6.05610639,1 6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,0 6,0 C6.16837661,0 6.33513331,0.00693566443 6.5,0.0205368885 L6.5,0.0207420343 Z M1.07163304,2.57725574 L1.94125309,3.07933111 C1.84488615,3.21358774 1.75424783,3.35385847 1.66987298,3.5 C1.58549813,3.64614153 1.50933927,3.79477198 1.44125309,3.94535651 L0.571633044,3.44328115 C0.642287382,3.2937018 0.719659271,3.14581842 0.803847577,3 C0.888035884,2.85418158 0.977420696,2.71323387 1.07163304,2.57725574 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

11
images/timer-50.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 50/timer50_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-50/timer50_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M8.49999998,1.66987313 L8.99999998,0.80384773 C10.3298113,1.57161469 11.3667294,2.84668755 11.7955548,4.4470858 C12.6532057,7.64788242 10.7537107,10.9379042 7.5529141,11.795555 C4.35211748,12.6532059 1.06209574,10.753711 0.204444873,7.55291434 C-0.27253249,5.77281059 0.103264647,3.96510985 1.08141192,2.56355986 L1.94944135,3.0683506 L1.94144625,3.07944279 L6.25000012,5.56698754 L5.75000012,6.43301294 L1.44144624,3.9454682 C0.98322086,4.96059039 0.85962347,6.13437085 1.1703707,7.29409529 C1.88507976,9.96142581 4.62676454,11.5443383 7.29409506,10.8296292 C9.96142557,10.1149201 11.544338,7.37323536 10.829629,4.70590484 C10.4722744,3.37223964 9.60817605,2.30967894 8.49999998,1.66987313 Z" id="Combined-Shape" fill="#000000"></path>
<path d="M6.00250506,1.00000061 L6,1 C5.8312503,1 5.66445312,1.00835972 5.5,1.02468762 L5.5,0.0205368885 C5.66486669,0.00693566443 5.83162339,0 6,0 L6.00250686,5.12480482e-07 C9.31506271,0.0013544265 12,2.68712686 12,6 L11,6 C11,3.23941132 8.76277746,1.00135396 6.00250506,1.00000061 Z M3.44328115,0.571633044 L3.94535651,1.44125309 C3.79477198,1.50933927 3.64614153,1.58549813 3.5,1.66987298 C3.35385847,1.75424783 3.21358774,1.84488615 3.07933111,1.94125309 L2.57725574,1.07163304 C2.71323387,0.977420696 2.85418158,0.888035884 3,0.803847577 C3.14581842,0.719659271 3.2937018,0.642287382 3.44328115,0.571633044 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

10
images/timer-55.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 55/timer55_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-55/timer55_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M10.8871515,4.93877116 C10.7764707,4.43108229 10.5875051,3.94579225 10.3301269,3.50000021 L10.3308482,3.49958378 C9.46665561,2.00598422 7.8519922,1.00090744 6.00250515,1.00000061 L6.00250515,4.75001343e-07 C8.87812128,0.0011754551 11.2807698,2.02530154 11.8645776,4.72650571 C12.0619293,5.63173625 12.0518761,6.59631008 11.7955548,7.55291434 C10.9379039,10.753711 7.64788218,12.6532059 4.44708556,11.795555 C1.24628894,10.9379042 -0.653205997,7.64788242 0.204444873,4.4470858 C0.681422235,2.66698205 1.91072247,1.28936585 3.45859785,0.564661592 L3.95793802,1.43583792 L3.94546796,1.44144648 L6.4330127,5.75000036 L5.5669873,6.25000036 L3.07944256,1.94144648 C2.17504663,2.5914554 1.48111793,3.54618039 1.1703707,4.70590484 C0.455661641,7.37323536 2.03857409,10.1149201 4.7059046,10.8296292 C7.37323512,11.5443383 10.1149199,9.96142581 10.829629,7.29409529 C10.9454889,6.86170024 11.0009697,6.42735124 11.0012398,6 L11,6 C11,5.63585356 10.9610724,5.28079915 10.8871515,4.93877116 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

10
images/timer-60.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>Icons/Timer 60/timer60_12</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Icons/Timer-60/timer60_12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M6.51360423,1.02605705 L6.5136035,1.02628879 L6.49999983,1.02491092 L6.49999983,6.0000004 L5.49999983,6.0000004 L5.49999983,1.02491092 C4.39176553,1.1356372 3.31344314,1.61548894 2.46446592,2.46446616 C0.511844466,4.41708762 0.511844466,7.58291251 2.46446592,9.53553397 C4.41708738,11.4881554 7.58291228,11.4881554 9.53553374,9.53553397 C10.5118444,8.55922329 10.9999998,7.27961177 10.9999998,6.00000024 L11.9999998,6.00000024 C11.9999998,7.53553409 11.4142133,9.07106792 10.2426405,10.2426408 C7.89949477,12.5857865 4.10050489,12.5857865 1.75735914,10.2426408 C-0.585786607,7.899495 -0.585786607,4.10050513 1.75735914,1.75735938 C3.01051112,0.504207398 4.68007561,-0.0787387759 6.32064441,0.00852085708 C9.4853004,0.175084568 12,2.7938725 12,6 L11,6 C11,3.411981 9.0337411,1.28320638 6.51360423,1.02605705 Z" id="Combined-Shape" fill="#000000"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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,

View File

@ -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);

View File

@ -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 });
},
});
})();

View File

@ -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;
}

View File

@ -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

View File

@ -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 {

1057
stylesheets/_modules.scss Normal file

File diff suppressed because it is too large Load Diff

View File

@ -19,5 +19,8 @@
@import 'theme_light';
@import 'theme_dark';
// New CSS
@import 'modules';
// Installer
@import 'options';

View File

@ -0,0 +1,98 @@
#### It's me!
```jsx
<ContactListItem
i18n={util.i18n}
isMe
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
verified
profileName="🔥Flames🔥"
avatarPath={util.gifObjectUrl}
onClick={() => console.log('onClick')}
/>
```
#### With name and profile
```jsx
<ContactListItem
i18n={util.i18n}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
avatarPath={util.gifObjectUrl}
onClick={() => console.log('onClick')}
/>
```
#### With name and profile, verified
```jsx
<ContactListItem
i18n={util.i18n}
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
verified
avatarPath={util.gifObjectUrl}
onClick={() => console.log('onClick')}
/>
```
#### With name and profile, no avatar
```jsx
<ContactListItem
i18n={util.i18n}
name="Someone 🔥 Somewhere"
color="teal"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
onClick={() => console.log('onClick')}
/>
```
#### Profile, no name, no avatar
```jsx
<ContactListItem
i18n={util.i18n}
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
onClick={() => console.log('onClick')}
/>
```
#### Verified, profile, no name, no avatar
```jsx
<ContactListItem
i18n={util.i18n}
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
verified
onClick={() => console.log('onClick')}
/>
```
#### No name, no profile, no avatar
```jsx
<ContactListItem
i18n={util.i18n}
phoneNumber="(202) 555-0011"
onClick={() => console.log('onClick')}
/>
```
#### Verified, no name, no profile, no avatar
```jsx
<ContactListItem
i18n={util.i18n}
phoneNumber="(202) 555-0011"
verified
onClick={() => console.log('onClick')}
/>
```

View File

@ -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<Props> {
public renderAvatar({ displayName }: { displayName: string }) {
const { avatarPath, i18n, color, name } = this.props;
if (avatarPath) {
return (
<div className="module-contact-list-item__avatar">
<img alt={i18n('contactAvatarAlt', [displayName])} src={avatarPath} />
</div>
);
}
const title = name ? getInitial(name) : '#';
return (
<div
className={classnames(
'module-contact-list-item__avatar-default',
`module-contact-list-item__avatar-default--${color}`
)}
>
<div className="module-contact-list-item__avatar-default__label">
{title}
</div>
</div>
);
}
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 ? (
<span className="module-contact-list-item__text__profile-name">
~<Emojify text={profileName} i18n={i18n} />
</span>
) : null;
const showNumber = isMe || name;
const showVerified = !isMe && verified;
return (
<div
role="button"
onClick={onClick}
className={classnames(
'module-contact-list-item',
onClick ? 'module-contact-list-item--with-click-handler' : null
)}
>
{this.renderAvatar({ displayName })}
<div className="module-contact-list-item__text">
<div className="module-contact-list-item__text__name">
<Emojify text={displayName} i18n={i18n} /> {profileElement}
</div>
<div className="module-contact-list-item__text__additional-data">
{showVerified ? (
<div className="module-contact-list-item__text__verified-icon" />
) : null}
{showVerified ? ` ${i18n('verified')}` : null}
{showVerified && showNumber ? ' ∙ ' : null}
{showNumber ? phoneNumber : null}
</div>
</div>
</div>
);
}
}

View File

@ -10,7 +10,7 @@ interface Props {
export class AddNewLines extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderNonNewLine: ({ text, key }) => <span key={key}>{text}</span>,
renderNonNewLine: ({ text }) => text,
};
public render() {

View File

@ -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<Props> {
public renderSendMessage({
hasSignalAccount,
i18n,
onSendMessage,
}: {
hasSignalAccount: boolean;
i18n: (key: string, values?: Array<string>) => 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 (
<div
className="module-contact-detail__send-message"
role="button"
onClick={onClick}
>
<button className="module-contact-detail__send-message__inner">
<div className="module-contact-detail__send-message__bubble-icon" />
{i18n('sendMessageToContact')}
</button>
</div>
);
}
public renderEmail(items: Array<Email> | undefined, i18n: Localizer) {
if (!items || items.length === 0) {
return;
@ -77,8 +110,13 @@ export class ContactDetail extends React.Component<Props> {
return items.map((item: Email) => {
return (
<div key={item.value} className="additional-contact">
<div className="type">{getLabelForEmail(item, i18n)}</div>
<div
key={item.value}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForEmail(item, i18n)}
</div>
{item.value}
</div>
);
@ -92,8 +130,13 @@ export class ContactDetail extends React.Component<Props> {
return items.map((item: Phone) => {
return (
<div key={item.value} className="additional-contact">
<div className="type">{getLabelForPhone(item, i18n)}</div>
<div
key={item.value}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForPhone(item, i18n)}
</div>
{item.value}
</div>
);
@ -142,8 +185,10 @@ export class ContactDetail extends React.Component<Props> {
return addresses.map((address: PostalAddress, index: number) => {
return (
<div key={index} className="additional-contact">
<div className="type">{getLabelForAddress(address, i18n)}</div>
<div key={index} className="module-contact-detail__additional-contact">
<div className="module-contact-detail__additional-contact__type">
{getLabelForAddress(address, i18n)}
</div>
{this.renderAddressLine(address.street)}
{this.renderPOBox(address.pobox, i18n)}
{this.renderAddressLine(address.neighborhood)}
@ -156,13 +201,15 @@ export class ContactDetail extends React.Component<Props> {
public render() {
const { contact, hasSignalAccount, i18n, onSendMessage } = this.props;
const isIncoming = false;
const module = 'contact-detail';
return (
<div className="contact-detail-component">
{renderAvatar(contact, i18n)}
{renderName(contact)}
{renderContactShorthand(contact)}
{renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
<div className="module-contact-detail">
{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)}

View File

@ -6,7 +6,7 @@
i18n={util.i18n}
isVerified
name="Someone 🔥 Somewhere"
phoneNumber="+12025550011"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
/>
</div>
@ -19,7 +19,7 @@
<ConversationTitle
i18n={util.i18n}
name="Someone 🔥 Somewhere"
phoneNumber="+12025550011"
phoneNumber="(202) 555-0011"
/>
</div>
```
@ -30,7 +30,7 @@
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ConversationTitle
i18n={util.i18n}
phoneNumber="+12025550011"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
/>
</div>
@ -40,6 +40,6 @@
```jsx
<div style={{ backgroundColor: 'gray', color: 'white' }}>
<ConversationTitle i18n={util.i18n} phoneNumber="+12025550011" />
<ConversationTitle i18n={util.i18n} phoneNumber="(202) 555-0011" />
</div>
```

View File

@ -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;
},
];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
</util.ConversationContext>;
```
#### 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,
},
},
},
];
<util.ConversationContext theme={util.theme}>
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
</util.ConversationContext>;
```
#### 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;
},
];
<util.ConversationContext theme={util.theme} type="group">
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
conversationType="group"
authorName="Mr. Fire"
authorAvatarPath={util.gifObjectUrl}
direction="incoming"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
color="green"
direction="incoming"
authorName="Mr. Fire"
conversationType="group"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
direction="outgoing"
conversationType="group"
authorName="Mr. Fire"
status="delivered"
i18n={util.i18n}
contacts={contacts}
contactHasSignalAccount
onClickContact={() => console.log('onClickContact')}
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
</util.ConversationContext>;
```
#### 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;
},
];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
</util.ConversationContext>;
```
#### 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;
},
];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
</util.ConversationContext>;
```
#### 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;
},
];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
</util.ConversationContext>;
```
#### 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,
},
],
},
];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
</util.ConversationContext>;
```
#### 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 = [{}];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
</util.ConversationContext>;
```
#### 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;
},
];
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
/>
<Message
text="I want to introduce you to Someone..."
color="green"
direction="incoming"
collapseMetadata
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
<Message
text="I want to introduce you to Someone..."
direction="outgoing"
collapseMetadata
status="delivered"
i18n={util.i18n}
contacts={contacts}
onClickContact={() => console.log('onClickContact')}
contactHasSignalAccount
onSendMessageToContact={() => console.log('onSendMessageToContact')}
/>
</util.ConversationContext>;
```

View File

@ -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<Props> {
public render() {
const {
contact,
hasSignalAccount,
i18n,
onOpenContact,
onSendMessage,
isIncoming,
onClickContact,
withContentAbove,
withContentBelow,
} = this.props;
const module = 'embedded-contact';
return (
<div className="embedded-contact" role="button" onClick={onOpenContact}>
<div className="first-line">
{renderAvatar(contact, i18n)}
<div className="text-container">
{renderName(contact)}
{renderContactShorthand(contact)}
</div>
<div
className={classnames(
'module-embedded-contact',
withContentAbove
? 'module-embedded-contact--with-content-above'
: null,
withContentBelow
? 'module-embedded-contact--with-content-below'
: null
)}
role="button"
onClick={onClickContact}
>
{renderAvatar({ contact, i18n, module })}
<div className="module-embedded-contact__text-container">
{renderName({ contact, isIncoming, module })}
{renderContactShorthand({ contact, isIncoming, module })}
</div>
{renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
</div>
);
}
@ -38,68 +54,85 @@ export class EmbeddedContact extends React.Component<Props> {
// 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 (
<div className="image-container">
<div className="default-avatar">{initials}</div>
<div className={`module-${module}__image-container`}>
<div className={`module-${module}__image-container__default-avatar`}>
{initials}
</div>
</div>
);
}
return (
<div className="image-container">
<img src={path} alt={i18n('contactAvatarAlt')} />
<div className={`module-${module}__image-container`}>
<img src={path} alt={i18n('contactAvatarAlt', [name])} />
</div>
);
}
export function renderName(contact: Contact) {
return <div className="contact-name">{getName(contact)}</div>;
export function renderName({
contact,
isIncoming,
module,
}: {
contact: Contact;
isIncoming: boolean;
module: string;
}) {
return (
<div
className={classnames(
`module-${module}__contact-name`,
isIncoming ? `module-${module}__contact-name--incoming` : null
)}
>
{getName(contact)}
</div>
);
}
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 <div className="contact-method">{firstNumber || firstEmail}</div>;
}
export function renderSendMessage(props: {
hasSignalAccount: boolean;
i18n: (key: string, values?: Array<string>) => 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 (
<div className="send-message" role="button" onClick={onClick}>
<button className="inner">
<div className="icon bubble-icon" />
{i18n('sendMessageToContact')}
</button>
<div
className={classnames(
`module-${module}__contact-method`,
isIncoming ? `module-${module}__contact-method--incoming` : null
)}
>
{firstNumber || firstEmail}
</div>
);
}

View File

@ -56,7 +56,7 @@ interface Props {
export class Emojify extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderNonEmoji: ({ text, key }) => <span key={key}>{text}</span>,
renderNonEmoji: ({ text }) => text,
};
public render() {

View File

@ -16,7 +16,7 @@ const SUPPORTED_PROTOCOLS = /^(http|https):/i;
export class Linkify extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
renderNonLink: ({ text, key }) => <span key={key}>{text}</span>,
renderNonLink: ({ text }) => text,
};
public render() {

File diff suppressed because it is too large Load Diff

View File

@ -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<Contact>;
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<QuotedAttachment>;
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<Props> {
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 (
<li className="entry outgoing sent delivered">
<span className="avatar" />
<div className="bubble">
<div className="sender" dir="auto" />
<div className="tail-wrapper with-tail">
<div className="inner-bubble">
<p className="content" dir="auto">
<span className="body">
Hi there. How are you doing? Feeling pretty good? Awesome.
</span>
</p>
<div
className={classnames(
'module-message__metadata__timer',
`module-message__metadata__timer--${bucket}`,
`module-message__metadata__timer--${direction}`,
withImageNoCaption
? 'module-message__metadata__timer--with-image-no-caption'
: null
)}
/>
);
}
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 (
<div
className={classnames(
'module-message__metadata',
withImageNoCaption
? 'module-message__metadata--with-image-no-caption'
: null
)}
>
<span
className={classnames(
'module-message__metadata__date',
`module-message__metadata__date--${direction}`,
withImageNoCaption
? 'module-message__metadata__date--with-image-no-caption'
: null
)}
title={moment(timestamp).format('llll')}
>
{formatRelativeTime(timestamp, { i18n, extended: true })}
</span>
{this.renderTimer()}
<span className="module-message__metadata__spacer" />
{direction === 'outgoing' ? (
<div
className={classnames(
'module-message__metadata__status-icon',
`module-message__metadata__status-icon-${status}`,
status === 'read'
? `module-message__metadata__status-icon-${color}`
: null,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null,
withImageNoCaption && status === 'read'
? 'module-message__metadata__status-icon--read-with-image-no-caption'
: null
)}
/>
) : null}
</div>
);
}
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 ? (
<span className="module-message__author__profile-name">
~<Emojify text={authorProfileName} i18n={i18n} />
</span>
) : null;
return (
<div className="module-message__author">
<Emojify text={title} i18n={i18n} /> {profileElement}
</div>
);
}
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 (
<div className="module-message__attachment-container">
<img
className={classnames(
'module-message__img-attachment',
withCaption
? 'module-message__img-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__img-attachment--with-content-above'
: null
)}
src={attachment.url}
alt={i18n('imageAttachmentAlt')}
onClick={onClickAttachment}
/>
{!withCaption && !collapseMetadata ? (
<div className="module-message__img-overlay" />
) : null}
</div>
);
} else if (isVideo(attachment)) {
return (
<video
controls={true}
className={classnames(
'module-message__img-attachment',
withCaption
? 'module-message__img-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__img-attachment--with-content-above'
: null
)}
>
<source src={attachment.url} />
</video>
);
} else if (isAudio(attachment)) {
return (
<audio
controls={true}
className={classnames(
'module-message__audio-attachment',
withContentBelow
? 'module-message__audio-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__audio-attachment--with-content-above'
: null
)}
>
<source src={attachment.url} />
</audio>
);
} else {
const { fileName, fileSize, contentType } = attachment;
const extension = getExtension({ contentType, fileName });
return (
<div
className={classnames(
'module-message__generic-attachment',
withContentBelow
? 'module-message__generic-attachment--with-content-below'
: null,
withContentAbove
? 'module-message__generic-attachment--with-content-above'
: null
)}
>
<div className="module-message__generic-attachment__icon">
{extension ? (
<div className="module-message__generic-attachment__icon__extension">
{extension}
</div>
) : null}
</div>
<div className="module-message__generic-attachment__text">
<div
className={classnames(
'module-message__generic-attachment__file-name',
`module-message__generic-attachment__file-name--${direction}`
)}
>
{fileName}
</div>
<div
className={classnames(
'module-message__generic-attachment__file-size',
`module-message__generic-attachment__file-size--${direction}`
)}
>
{fileSize}
</div>
</div>
<div className="meta">
<span
className="timestamp"
data-timestamp="1522800995425"
title="Tue, Apr 3, 2018 5:16 PM"
>
1 minute ago
</span>
<span className="status hide" />
<span className="timer" />
</div>
</div>
);
}
}
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 (
<Quote
i18n={i18n}
onClick={onClickQuote}
color={color}
text={quote.text}
attachments={quote.attachments}
isIncoming={direction === 'incoming'}
authorTitle={authorTitle || ''}
authorProfileName={authorProfileName}
isFromMe={quote.isFromMe}
withContentAbove={withContentAbove}
/>
);
}
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 (
<EmbeddedContact
contact={first}
hasSignalAccount={contactHasSignalAccount}
isIncoming={direction === 'incoming'}
i18n={i18n}
onSendMessage={onSendMessageToContact}
onClickContact={onClickContact}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
/>
);
}
public renderSendMessageButton() {
const {
contactHasSignalAccount,
contacts,
i18n,
onSendMessageToContact,
} = this.props;
const first = contacts && contacts[0];
if (!first || !contactHasSignalAccount) {
return null;
}
return (
<div
role="button"
onClick={onSendMessageToContact}
className="module-message__send-message-button"
>
{i18n('sendMessageToContact')}
</div>
);
}
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 (
<div
className={classnames(
'module-message__author-default-avatar',
`module-message__author-default-avatar--${color}`
)}
>
<div className="module-message__author-default-avatar__label">#</div>
</div>
);
}
return (
<div className="module-message__author-avatar">
<img alt={i18n('contactAvatarAlt', [title])} src={authorAvatarPath} />
</div>
);
}
public renderText() {
const { text, i18n, direction } = this.props;
if (!text) {
return null;
}
return (
<div
className={classnames(
'module-message__text',
`module-message__text--${direction}`
)}
>
<MessageBody text={text || ''} i18n={i18n} />
</div>
);
}
public render() {
const {
attachment,
color,
conversationType,
direction,
id,
quote,
text,
} = this.props;
const imageAndNothingElse =
!text && isImage(attachment) && conversationType !== 'group' && !quote;
return (
<li>
<div
id={id}
className={classnames(
'module-message',
`module-message--${direction}`,
imageAndNothingElse ? 'module-message--with-image-only' : null,
direction === 'incoming'
? `module-message--incoming-${color}`
: null
)}
>
{this.renderAuthor()}
{this.renderQuote()}
{this.renderAttachment()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
{this.renderAvatar()}
</div>
</li>
);

View File

@ -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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: fromOther }} />
<util.BackboneWrapper View={View} options={{ model: fromUpdate }} />
<util.BackboneWrapper View={View} options={{ model: fromMe }} />
<Notification type="timerUpdate" onClick={() => console.log('onClick')} />
</util.ConversationContext>;
```
### Safety number change
```js
const incoming = new Whisper.Message({
type: 'keychange',
sent_at: Date.now() - 200000,
key_changed: '+12025550003',
});
const View = Whisper.KeyChangeView;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
</util.ConversationContext>;
```
### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
<util.BackboneWrapper View={View} options={{ model: local }} />
</util.ConversationContext>;
```
### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
<util.BackboneWrapper View={View} options={{ model: local }} />
</util.ConversationContext>;
```
### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```

View File

@ -0,0 +1,32 @@
import React from 'react';
import classnames from 'classnames';
interface Props {
type: string;
onClick: () => void;
}
export class Notification extends React.Component<Props> {
public renderContents() {
const { type } = this.props;
return <span>Notification of type {type}</span>;
}
public render() {
const { onClick } = this.props;
return (
<div
role="button"
onClick={onClick}
className={classnames(
'module-notification',
onClick ? 'module-notification--with-click-handler' : null
)}
>
{this.renderContents()}
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<QuotedAttachment>;
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<Props> {
public renderImage(url: string, i18n: Localizer, icon?: string) {
const iconElement = icon ? (
<div className={classNames('icon', 'with-image', icon)} />
<div className="module-quote__icon-container__inner">
<div className="module-quote__icon-container__circle-background">
<div
className={classnames(
'module-quote__icon-container__icon',
`module-quote__icon-container__icon--${icon}`
)}
/>
</div>
</div>
) : null;
return (
<div className="icon-container">
<div className="inner">
<img src={url} alt={i18n('quoteThumbnailAlt')} />
{iconElement}
</div>
<div className="module-quote__icon-container">
<img src={url} alt={i18n('quoteThumbnailAlt')} />
{iconElement}
</div>
);
}
public renderIcon(icon: string) {
const { authorColor, isIncoming } = this.props;
return (
<div className="module-quote__icon-container">
<div className="module-quote__icon-container__inner">
<div className="module-quote__icon-container__circle-background">
<div
className={classnames(
'module-quote__icon-container__icon',
`module-quote__icon-container__icon--${icon}`
)}
/>
</div>
</div>
</div>
);
}
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 (
<div className="icon-container">
<div className={classNames('circle-background', backgroundColor)} />
<div className={classNames('icon', icon, iconColor)} />
<div className="module-quote__generic-file">
<div className="module-quote__generic-file__icon" />
<div className="module-quote__generic-file__text">{fileName}</div>
</div>
);
}
@ -111,7 +173,7 @@ export class Quote extends React.Component<Props> {
return this.renderIcon('microphone');
}
return this.renderIcon('file');
return null;
}
public renderText() {
@ -119,7 +181,7 @@ export class Quote extends React.Component<Props> {
if (text) {
return (
<div className="text">
<div className="module-quote__primary__text">
<MessageBody text={text} i18n={i18n} />
</div>
);
@ -130,43 +192,16 @@ export class Quote extends React.Component<Props> {
}
const first = attachments[0];
const { contentType, fileName, isVoiceMessage } = first;
const { contentType, isVoiceMessage } = first;
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return <div className="type-label">{i18n('video')}</div>;
}
if (GoogleChrome.isImageTypeSupported(contentType)) {
return <div className="type-label">{i18n('photo')}</div>;
}
if (MIME.isAudio(contentType) && isVoiceMessage) {
return <div className="type-label">{i18n('voiceMessage')}</div>;
}
if (MIME.isAudio(contentType)) {
return <div className="type-label">{i18n('audio')}</div>;
const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage });
if (typeLabel) {
return (
<div className="module-quote__primary__type-label">{typeLabel}</div>
);
}
return <div className="filename-label">{fileName}</div>;
}
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 <div className="ios-label">{label}</div>;
return null;
}
public renderClose() {
@ -185,29 +220,27 @@ export class Quote extends React.Component<Props> {
// We need the container to give us the flexibility to implement the iOS design.
return (
<div className="close-container">
<div className="close-button" role="button" onClick={onClick} />
<div className="module-quote__close-container">
<div
className="module-quote__close-button"
role="button"
onClick={onClick}
/>
</div>
);
}
public renderAuthor() {
const {
authorColor,
authorProfileName,
authorTitle,
i18n,
isFromMe,
} = this.props;
const { authorProfileName, authorTitle, i18n, isFromMe } = this.props;
const authorProfileElement = authorProfileName ? (
<span className="profile-name">
<span className="module-quote__primary__profile-name">
~<Emojify text={authorProfileName} i18n={i18n} />
</span>
) : null;
return (
<div className={classNames(authorColor, 'author')}>
<div className="module-quote__primary__author">
{isFromMe ? (
i18n('you')
) : (
@ -220,24 +253,27 @@ export class Quote extends React.Component<Props> {
}
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 (
<div onClick={onClick} role="button" className={classes}>
<div className="primary">
{this.renderIOSLabel()}
<div
onClick={onClick}
role="button"
className={classnames(
'module-quote',
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
!isIncoming ? `module-quote--outgoing-${color}` : null,
!onClick ? 'module-quote--no-click' : null,
withContentAbove ? 'module-quote--with-content-above' : null
)}
>
<div className="module-quote__primary">
{this.renderAuthor()}
{this.renderGenericFile()}
{this.renderText()}
</div>
{this.renderIconContainer()}

View File

@ -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,

View File

@ -3,6 +3,6 @@ export type RenderTextCallback = (
text: string;
key: number;
}
) => JSX.Element;
) => JSX.Element | string;
export type Localizer = (key: string, values?: Array<string>) => string;

View File

@ -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');
}

70
ts/util/migrateColor.ts Normal file
View File

@ -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';
}
}