Move left pane entirely to React
This commit is contained in:
		
					parent
					
						
							
								bf904ddd12
							
						
					
				
			
			
				commit
				
					
						b3ac1373fa
					
				
			
		
					 142 changed files with 5016 additions and 3428 deletions
				
			
		| 
						 | 
				
			
			@ -9,6 +9,11 @@
 | 
			
		|||
    "description":
 | 
			
		||||
      "Shown in the top-level error popup, allowing user to copy the error text and close the app"
 | 
			
		||||
  },
 | 
			
		||||
  "unknownGroup": {
 | 
			
		||||
    "message": "Unknown group",
 | 
			
		||||
    "description":
 | 
			
		||||
      "Shown as the name of a group if we don't have any information about it"
 | 
			
		||||
  },
 | 
			
		||||
  "databaseError": {
 | 
			
		||||
    "message": "Database Error",
 | 
			
		||||
    "description": "Shown in a popup if the database cannot start up properly"
 | 
			
		||||
| 
						 | 
				
			
			@ -710,10 +715,32 @@
 | 
			
		|||
    "message": "Signal Desktop",
 | 
			
		||||
    "description": "Tooltip for the tray icon"
 | 
			
		||||
  },
 | 
			
		||||
  "searchForPeopleOrGroups": {
 | 
			
		||||
    "message": "Enter name or number",
 | 
			
		||||
  "search": {
 | 
			
		||||
    "message": "Search",
 | 
			
		||||
    "description": "Placeholder text in the search input"
 | 
			
		||||
  },
 | 
			
		||||
  "noSearchResults": {
 | 
			
		||||
    "message": "No results for \"$searchTerm$\"",
 | 
			
		||||
    "description": "Shown in the search left pane when no results were found",
 | 
			
		||||
    "placeholders": {
 | 
			
		||||
      "searchTerm": {
 | 
			
		||||
        "content": "$1",
 | 
			
		||||
        "example": "dog"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "conversationsHeader": {
 | 
			
		||||
    "message": "Conversations",
 | 
			
		||||
    "description": "Shown to separate the types of search results"
 | 
			
		||||
  },
 | 
			
		||||
  "contactsHeader": {
 | 
			
		||||
    "message": "Contacts",
 | 
			
		||||
    "description": "Shown to separate the types of search results"
 | 
			
		||||
  },
 | 
			
		||||
  "messagesHeader": {
 | 
			
		||||
    "message": "Messages",
 | 
			
		||||
    "description": "Shown to separate the types of search results"
 | 
			
		||||
  },
 | 
			
		||||
  "welcomeToSignal": {
 | 
			
		||||
    "message": "Welcome to Signal"
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -883,7 +910,7 @@
 | 
			
		|||
    "description": "Label for the sender of a message"
 | 
			
		||||
  },
 | 
			
		||||
  "to": {
 | 
			
		||||
    "message": "To",
 | 
			
		||||
    "message": "to",
 | 
			
		||||
    "description": "Label for the receiver of a message"
 | 
			
		||||
  },
 | 
			
		||||
  "sent": {
 | 
			
		||||
| 
						 | 
				
			
			@ -1567,7 +1594,7 @@
 | 
			
		|||
    "description": "Label text for menu bar visibility setting"
 | 
			
		||||
  },
 | 
			
		||||
  "startConversation": {
 | 
			
		||||
    "message": "Start conversation…",
 | 
			
		||||
    "message": "Start new conversation…",
 | 
			
		||||
    "description":
 | 
			
		||||
      "Label underneath number a user enters that is not an existing contact"
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1146,7 +1146,7 @@ async function getAllGroupsInvolvingId(id) {
 | 
			
		|||
  return map(rows, row => jsonToObject(row.json));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function searchConversations(query) {
 | 
			
		||||
async function searchConversations(query, { limit } = {}) {
 | 
			
		||||
  const rows = await db.all(
 | 
			
		||||
    `SELECT json FROM conversations WHERE
 | 
			
		||||
      (
 | 
			
		||||
| 
						 | 
				
			
			@ -1154,11 +1154,13 @@ async function searchConversations(query) {
 | 
			
		|||
        name LIKE $name OR
 | 
			
		||||
        profileName LIKE $profileName
 | 
			
		||||
      )
 | 
			
		||||
     ORDER BY id ASC;`,
 | 
			
		||||
     ORDER BY id ASC
 | 
			
		||||
     LIMIT $limit`,
 | 
			
		||||
    {
 | 
			
		||||
      $id: `%${query}%`,
 | 
			
		||||
      $name: `%${query}%`,
 | 
			
		||||
      $profileName: `%${query}%`,
 | 
			
		||||
      $limit: limit || 50,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										153
									
								
								background.html
									
										
									
									
									
								
							
							
						
						
									
										153
									
								
								background.html
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -38,6 +38,7 @@
 | 
			
		|||
      <div class='message'>{{ message }}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='conversation-loading-screen'>
 | 
			
		||||
    <div class='content'>
 | 
			
		||||
      <img src='images/icon_128.png'>
 | 
			
		||||
| 
						 | 
				
			
			@ -48,20 +49,11 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='two-column'>
 | 
			
		||||
    <div class='gutter'>
 | 
			
		||||
      <div class='network-status-container'></div>
 | 
			
		||||
      <div class='main-header-placeholder'></div>
 | 
			
		||||
      <div class='tool-bar clearfix'>
 | 
			
		||||
        <input type='search' class='search' placeholder='{{ searchForPeopleOrGroups }}' dir='auto'>
 | 
			
		||||
        <span class='search-icon'></span>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class='content'>
 | 
			
		||||
        <div class='conversations inbox'></div>
 | 
			
		||||
        <div class='conversations search-results hide'>
 | 
			
		||||
          <div class='new-contact contact hide'></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class='left-pane-placeholder'></div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class='conversation-stack'>
 | 
			
		||||
      <div class='conversation placeholder'>
 | 
			
		||||
| 
						 | 
				
			
			@ -77,23 +69,27 @@
 | 
			
		|||
    </div>
 | 
			
		||||
    <div class='lightbox-container'></div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
 | 
			
		||||
    <button class='text module-scroll-down__button {{ buttonClass }}' alt='{{ moreBelow }}'>
 | 
			
		||||
      <div class='module-scroll-down__icon'></div>
 | 
			
		||||
    </button>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='last-seen-indicator-view'>
 | 
			
		||||
    <div class='module-last-seen-indicator__bar'/>
 | 
			
		||||
    <div class='module-last-seen-indicator__text'>
 | 
			
		||||
      {{ unreadMessages }}
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='expired_alert'>
 | 
			
		||||
    <a target='_blank' href='https://signal.org/download/'>
 | 
			
		||||
      <button class='upgrade'>{{ upgrade }}</button>
 | 
			
		||||
    </a>
 | 
			
		||||
    {{ expiredWarning }}
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='banner'>
 | 
			
		||||
    <div class='body'>
 | 
			
		||||
      <span class='icon warning'></span>
 | 
			
		||||
| 
						 | 
				
			
			@ -101,12 +97,11 @@
 | 
			
		|||
      <span class='icon dismiss'></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='toast'>
 | 
			
		||||
    {{ toastMessage }}
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='hint'>
 | 
			
		||||
    <p> {{ content }}</p>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='conversation'>
 | 
			
		||||
    <div class='conversation-header'></div>
 | 
			
		||||
    <div class='main panel'>
 | 
			
		||||
| 
						 | 
				
			
			@ -140,15 +135,18 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='message-list'>
 | 
			
		||||
    <div class='messages'></div>
 | 
			
		||||
    <div class='typing-container'></div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='recorder'>
 | 
			
		||||
      <button class='finish'><span class='icon'></span></button>
 | 
			
		||||
      <span class='time'>0:00</span>
 | 
			
		||||
      <button class='close'><span class='icon'></span></button>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='confirmation-dialog'>
 | 
			
		||||
    <div class="content">
 | 
			
		||||
      <div class='message'>{{ message }}</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -160,26 +158,7 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='file-view'>
 | 
			
		||||
    <div class='icon {{ mediaType }}'></div>
 | 
			
		||||
    <div class='text'>
 | 
			
		||||
      <div class='fileName' title='{{ altText }}'>
 | 
			
		||||
        {{ fileName }}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class='fileSize'>{{ fileSize }}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='new-group-update'>
 | 
			
		||||
      <div class='conversation-header'>
 | 
			
		||||
        <button class='back'></button>
 | 
			
		||||
        <button class='send check'></button>
 | 
			
		||||
        <span class='conversation-title'>Update group</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      {{> group_info_input }}
 | 
			
		||||
      <div class='container'>
 | 
			
		||||
        <div class='scrollable'></div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='identicon-svg'>
 | 
			
		||||
      <svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>
 | 
			
		||||
         <circle cx='50' cy='50' r='40' fill='{{ color }}' />
 | 
			
		||||
| 
						 | 
				
			
			@ -188,54 +167,7 @@
 | 
			
		|||
          </text>
 | 
			
		||||
      </svg>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='avatar'>
 | 
			
		||||
    <span aria-hidden class='avatar
 | 
			
		||||
      {{ ^avatar.url }}
 | 
			
		||||
        {{ avatar.color }}
 | 
			
		||||
      {{ /avatar.url }}
 | 
			
		||||
      '
 | 
			
		||||
      {{ #avatar.url }}
 | 
			
		||||
        style='background-image: url("{{ avatar.url }}");'
 | 
			
		||||
      {{ /avatar.url }}
 | 
			
		||||
    >
 | 
			
		||||
        {{ avatar.content }}
 | 
			
		||||
    </span>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='contact_pill'>
 | 
			
		||||
    <span>{{ name }}</span><span class='remove'>x</span>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='contact_name_and_number'>
 | 
			
		||||
    <h3 class='name' dir='auto'>
 | 
			
		||||
      {{ title }}
 | 
			
		||||
      {{ #profileName }}
 | 
			
		||||
        <span class='profileName'>{{ profileName }} </span>
 | 
			
		||||
      {{ /profileName }}
 | 
			
		||||
    </h3>
 | 
			
		||||
    <div class='number'>{{ #isVerified }}<span class='verified-icon'></span> {{ verified }} ·{{ /isVerified }} {{ number }}</div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='contact'>
 | 
			
		||||
      {{> avatar }}
 | 
			
		||||
      <div class='contact-details {{ class }}'> {{> contact_name_and_number }} </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='new-contact'>
 | 
			
		||||
      {{> avatar }}
 | 
			
		||||
      <div class='contact-details'>
 | 
			
		||||
          {{> contact_name_and_number }}
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='conversation-preview'>
 | 
			
		||||
      {{> avatar }}
 | 
			
		||||
      <div class='contact-details'>
 | 
			
		||||
          <span class='last-timestamp' data-timestamp='{{ last_message_timestamp }}' dir='auto' > </span>
 | 
			
		||||
          <h3 class='name' dir='auto'></h3>
 | 
			
		||||
          {{ #unreadCount }}
 | 
			
		||||
            <span class='unread-count'>{{ unreadCount }}</span>
 | 
			
		||||
          {{ /unreadCount }}
 | 
			
		||||
          {{ #last_message }}
 | 
			
		||||
            <p class='last-message' dir='auto'></p>
 | 
			
		||||
          {{ /last_message }}
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='phone-number'>
 | 
			
		||||
      <div class='phone-input-form'>
 | 
			
		||||
          <div class='number-container'>
 | 
			
		||||
| 
						 | 
				
			
			@ -243,18 +175,22 @@
 | 
			
		|||
          </div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='file-size-modal'>
 | 
			
		||||
      {{ file-size-warning }}
 | 
			
		||||
      ({{ limit }}{{ units }})
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='attachment-type-modal'>
 | 
			
		||||
      Sorry, your attachment has a type, {{type}}, that is not currently supported.
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='group-member-list'>
 | 
			
		||||
    <div class='container'>
 | 
			
		||||
      {{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='key-verification'>
 | 
			
		||||
    <div class='container'>
 | 
			
		||||
      {{ ^hasTheirKey }}
 | 
			
		||||
| 
						 | 
				
			
			@ -285,52 +221,7 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <!-- index -->
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='group_info_input'>
 | 
			
		||||
      <div class='group-info-input'>
 | 
			
		||||
        <div class='group-avatar'>
 | 
			
		||||
          <div class='choose-file attachment-previews thumbnail'>
 | 
			
		||||
            {{> avatar }}
 | 
			
		||||
            </div>
 | 
			
		||||
          <input type='file' name='avatar' class='file-input'>
 | 
			
		||||
        </div>
 | 
			
		||||
        <input type='text' name='name' class='name' placeholder='Group Name' value='{{ name }}'>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='new-conversation'>
 | 
			
		||||
      <div class='conversation-header'>
 | 
			
		||||
        <button class='back'></button>
 | 
			
		||||
        <button class='create check hide'></button>
 | 
			
		||||
        <span class='conversation-title'>New Message</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      {{> group_info_input }}
 | 
			
		||||
      <div class='container'>
 | 
			
		||||
        <div class='scrollable'>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='recipients-input'>
 | 
			
		||||
      <div class='recipients-container'>
 | 
			
		||||
        <span class='recipients'></span>
 | 
			
		||||
        <input type='text' class='search' placeholder='{{ placeholder }}' dir='auto' />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class='results'>
 | 
			
		||||
        <div class='new-contact contact hide'></div>
 | 
			
		||||
        <div class='contacts'></div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='error-icon'>
 | 
			
		||||
    <span class='error-icon'>
 | 
			
		||||
    </span>
 | 
			
		||||
    {{ #message }}
 | 
			
		||||
      <span class='error-message'>{{message}}</span>
 | 
			
		||||
    {{ /message }}
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='link_to_support'>
 | 
			
		||||
    <a href='http://support.signal.org/hc/articles/213134107' target='_blank'>
 | 
			
		||||
      {{ learnMore }}
 | 
			
		||||
    </a>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='clear-data'>
 | 
			
		||||
    {{#isStep1}}
 | 
			
		||||
    <div class='step'>
 | 
			
		||||
| 
						 | 
				
			
			@ -612,19 +503,13 @@
 | 
			
		|||
  <script type='text/javascript' src='js/views/toast_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/file_input_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/list_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/conversation_list_item_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/conversation_list_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/contact_list_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/attachment_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/timestamp_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/message_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/key_verification_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/message_list_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/group_member_list_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/recorder_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/conversation_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/conversation_search_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/hint_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/inbox_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/network_status_view.js'></script>
 | 
			
		||||
  <script type='text/javascript' src='js/views/confirmation_dialog_view.js'></script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
 | 
			
		||||
    <path d="M31 28h-1.59l-.55-.55C30.82 25.18 32 22.23 32 19c0-7.18-5.82-13-13-13S6 11.82 6 19s5.82 13 13 13c3.23 0 6.18-1.18 8.45-3.13l.55.55V31l10 9.98L40.98 38 31 28zm-12 0c-4.97 0-9-4.03-9-9s4.03-9 9-9 9 4.03 9 9-4.03 9-9 9z"/>
 | 
			
		||||
    <path d="M0 0h48v48H0z" fill="none"/>
 | 
			
		||||
</svg>
 | 
			
		||||
<svg id="Final" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>search-16</title><path d="M11.34,10.28a1.14,1.14,0,0,1-1-.29,1,1,0,0,1-.18-.27,5.31,5.31,0,1,0-.44.44,1,1,0,0,1,.27.18,1.14,1.14,0,0,1,.29,1L13.94,15,15,13.94ZM6.25,10A3.75,3.75,0,1,1,10,6.25,3.75,3.75,0,0,1,6.25,10Z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 303 B  | 
| 
						 | 
				
			
			@ -177,6 +177,12 @@
 | 
			
		|||
      const PASSWORD = storage.get('password');
 | 
			
		||||
      accountManager = new textsecure.AccountManager(USERNAME, PASSWORD);
 | 
			
		||||
      accountManager.addEventListener('registration', () => {
 | 
			
		||||
        const user = {
 | 
			
		||||
          regionCode: window.storage.get('regionCode'),
 | 
			
		||||
          ourNumber: textsecure.storage.user.getNumber(),
 | 
			
		||||
        };
 | 
			
		||||
        Whisper.events.trigger('userChanged', user);
 | 
			
		||||
 | 
			
		||||
        Whisper.Registration.markDone();
 | 
			
		||||
        window.log.info('dispatching registration event');
 | 
			
		||||
        Whisper.events.trigger('registration_done');
 | 
			
		||||
| 
						 | 
				
			
			@ -535,16 +541,16 @@
 | 
			
		|||
    window.addEventListener('focus', () => Whisper.Notifications.clear());
 | 
			
		||||
    window.addEventListener('unload', () => Whisper.Notifications.fastClear());
 | 
			
		||||
 | 
			
		||||
    Whisper.events.on('showConversation', conversation => {
 | 
			
		||||
    Whisper.events.on('showConversation', (id, messageId) => {
 | 
			
		||||
      if (appView) {
 | 
			
		||||
        appView.openConversation(conversation);
 | 
			
		||||
        appView.openConversation(id, messageId);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    Whisper.Notifications.on('click', conversation => {
 | 
			
		||||
    Whisper.Notifications.on('click', (id, messageId) => {
 | 
			
		||||
      window.showWindow();
 | 
			
		||||
      if (conversation) {
 | 
			
		||||
        appView.openConversation(conversation);
 | 
			
		||||
      if (id) {
 | 
			
		||||
        appView.openConversation(id, messageId);
 | 
			
		||||
      } else {
 | 
			
		||||
        appView.openInbox({
 | 
			
		||||
          initialLoadComplete,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,25 +21,6 @@
 | 
			
		|||
        _.debounce(this.updateUnreadCount.bind(this), 1000)
 | 
			
		||||
      );
 | 
			
		||||
      this.startPruning();
 | 
			
		||||
 | 
			
		||||
      this.collator = new Intl.Collator();
 | 
			
		||||
    },
 | 
			
		||||
    comparator(m1, m2) {
 | 
			
		||||
      const timestamp1 = m1.get('timestamp');
 | 
			
		||||
      const timestamp2 = m2.get('timestamp');
 | 
			
		||||
      if (timestamp1 && !timestamp2) {
 | 
			
		||||
        return -1;
 | 
			
		||||
      }
 | 
			
		||||
      if (timestamp2 && !timestamp1) {
 | 
			
		||||
        return 1;
 | 
			
		||||
      }
 | 
			
		||||
      if (timestamp1 && timestamp2 && timestamp1 !== timestamp2) {
 | 
			
		||||
        return timestamp2 - timestamp1;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const title1 = m1.getTitle().toLowerCase();
 | 
			
		||||
      const title2 = m2.getTitle().toLowerCase();
 | 
			
		||||
      return this.collator.compare(title1, title2);
 | 
			
		||||
    },
 | 
			
		||||
    addActive(model) {
 | 
			
		||||
      if (model.get('active_at')) {
 | 
			
		||||
| 
						 | 
				
			
			@ -78,18 +59,6 @@
 | 
			
		|||
  window.getInboxCollection = () => inboxCollection;
 | 
			
		||||
 | 
			
		||||
  window.ConversationController = {
 | 
			
		||||
    markAsSelected(toSelect) {
 | 
			
		||||
      conversations.each(conversation => {
 | 
			
		||||
        const current = conversation.isSelected || false;
 | 
			
		||||
        const newValue = conversation.id === toSelect.id;
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line no-param-reassign
 | 
			
		||||
        conversation.isSelected = newValue;
 | 
			
		||||
        if (current !== newValue) {
 | 
			
		||||
          conversation.trigger('change');
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    get(id) {
 | 
			
		||||
      if (!this._initialFetchComplete) {
 | 
			
		||||
        throw new Error(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,12 @@
 | 
			
		|||
            Message: Whisper.Message,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          Whisper.events.trigger(
 | 
			
		||||
            'messageExpired',
 | 
			
		||||
            message.id,
 | 
			
		||||
            message.conversationId
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          const conversation = message.getConversation();
 | 
			
		||||
          if (conversation) {
 | 
			
		||||
            conversation.trigger('expired', message);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,14 @@
 | 
			
		|||
/* global _: false */
 | 
			
		||||
/* global Backbone: false */
 | 
			
		||||
/* global libphonenumber: false */
 | 
			
		||||
 | 
			
		||||
/* global ConversationController: false */
 | 
			
		||||
/* global libsignal: false */
 | 
			
		||||
/* global storage: false */
 | 
			
		||||
/* global textsecure: false */
 | 
			
		||||
/* global Whisper: false */
 | 
			
		||||
/* global
 | 
			
		||||
  _,
 | 
			
		||||
  i18n,
 | 
			
		||||
  Backbone,
 | 
			
		||||
  libphonenumber,
 | 
			
		||||
  ConversationController,
 | 
			
		||||
  libsignal,
 | 
			
		||||
  storage,
 | 
			
		||||
  textsecure,
 | 
			
		||||
  Whisper
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
/* eslint-disable more/no-then */
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -138,6 +140,13 @@
 | 
			
		|||
 | 
			
		||||
      this.typingRefreshTimer = null;
 | 
			
		||||
      this.typingPauseTimer = null;
 | 
			
		||||
 | 
			
		||||
      // Keep props ready
 | 
			
		||||
      const generateProps = () => {
 | 
			
		||||
        this.cachedProps = this.getProps();
 | 
			
		||||
      };
 | 
			
		||||
      this.on('change', generateProps);
 | 
			
		||||
      generateProps();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    isMe() {
 | 
			
		||||
| 
						 | 
				
			
			@ -292,40 +301,37 @@
 | 
			
		|||
    },
 | 
			
		||||
 | 
			
		||||
    format() {
 | 
			
		||||
      return this.cachedProps;
 | 
			
		||||
    },
 | 
			
		||||
    getProps() {
 | 
			
		||||
      const { format } = PhoneNumber;
 | 
			
		||||
      const regionCode = storage.get('regionCode');
 | 
			
		||||
      const color = this.getColor();
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        phoneNumber: format(this.id, {
 | 
			
		||||
          ourRegionCode: regionCode,
 | 
			
		||||
        }),
 | 
			
		||||
        color,
 | 
			
		||||
        avatarPath: this.getAvatarPath(),
 | 
			
		||||
        name: this.getName(),
 | 
			
		||||
        profileName: this.getProfileName(),
 | 
			
		||||
        title: this.getTitle(),
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    getPropsForListItem() {
 | 
			
		||||
      const typingKeys = Object.keys(this.contactTypingTimers || {});
 | 
			
		||||
 | 
			
		||||
      const result = {
 | 
			
		||||
        ...this.format(),
 | 
			
		||||
        id: this.id,
 | 
			
		||||
 | 
			
		||||
        activeAt: this.get('active_at'),
 | 
			
		||||
        avatarPath: this.getAvatarPath(),
 | 
			
		||||
        color,
 | 
			
		||||
        type: this.isPrivate() ? 'direct' : 'group',
 | 
			
		||||
        isMe: this.isMe(),
 | 
			
		||||
        conversationType: this.isPrivate() ? 'direct' : 'group',
 | 
			
		||||
 | 
			
		||||
        lastUpdated: this.get('timestamp'),
 | 
			
		||||
        unreadCount: this.get('unreadCount') || 0,
 | 
			
		||||
        isSelected: this.isSelected,
 | 
			
		||||
 | 
			
		||||
        isTyping: typingKeys.length > 0,
 | 
			
		||||
        lastUpdated: this.get('timestamp'),
 | 
			
		||||
        name: this.getName(),
 | 
			
		||||
        profileName: this.getProfileName(),
 | 
			
		||||
        timestamp: this.get('timestamp'),
 | 
			
		||||
        title: this.getTitle(),
 | 
			
		||||
        unreadCount: this.get('unreadCount') || 0,
 | 
			
		||||
 | 
			
		||||
        phoneNumber: format(this.id, {
 | 
			
		||||
          ourRegionCode: regionCode,
 | 
			
		||||
        }),
 | 
			
		||||
        lastMessage: {
 | 
			
		||||
          status: this.get('lastMessageStatus'),
 | 
			
		||||
          text: this.get('lastMessage'),
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        onClick: () => this.trigger('select', this),
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      return result;
 | 
			
		||||
| 
						 | 
				
			
			@ -572,8 +578,8 @@
 | 
			
		|||
    onMemberVerifiedChange() {
 | 
			
		||||
      // If the verified state of a member changes, our aggregate state changes.
 | 
			
		||||
      // We trigger both events to replicate the behavior of Backbone.Model.set()
 | 
			
		||||
      this.trigger('change:verified');
 | 
			
		||||
      this.trigger('change');
 | 
			
		||||
      this.trigger('change:verified', this);
 | 
			
		||||
      this.trigger('change', this);
 | 
			
		||||
    },
 | 
			
		||||
    toggleVerified() {
 | 
			
		||||
      if (this.isVerified()) {
 | 
			
		||||
| 
						 | 
				
			
			@ -1798,7 +1804,7 @@
 | 
			
		|||
      if (this.isPrivate()) {
 | 
			
		||||
        return this.get('name');
 | 
			
		||||
      }
 | 
			
		||||
      return this.get('name') || 'Unknown group';
 | 
			
		||||
      return this.get('name') || i18n('unknownGroup');
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    getTitle() {
 | 
			
		||||
| 
						 | 
				
			
			@ -1990,14 +1996,14 @@
 | 
			
		|||
        if (!record) {
 | 
			
		||||
          // User was not previously typing before. State change!
 | 
			
		||||
          this.trigger('typing-update');
 | 
			
		||||
          this.trigger('change');
 | 
			
		||||
          this.trigger('change', this);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        delete this.contactTypingTimers[identifier];
 | 
			
		||||
        if (record) {
 | 
			
		||||
          // User was previously typing, and is no longer. State change!
 | 
			
		||||
          this.trigger('typing-update');
 | 
			
		||||
          this.trigger('change');
 | 
			
		||||
          this.trigger('change', this);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -2012,7 +2018,7 @@
 | 
			
		|||
 | 
			
		||||
        // User was previously typing, but timed out or we received message. State change!
 | 
			
		||||
        this.trigger('typing-update');
 | 
			
		||||
        this.trigger('change');
 | 
			
		||||
        this.trigger('change', this);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			@ -2034,21 +2040,6 @@
 | 
			
		|||
      );
 | 
			
		||||
      this.reset([]);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async search(providedQuery) {
 | 
			
		||||
      let query = providedQuery.trim().toLowerCase();
 | 
			
		||||
      query = query.replace(/[+-.()]*/g, '');
 | 
			
		||||
 | 
			
		||||
      if (query.length === 0) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const collection = await window.Signal.Data.searchConversations(query, {
 | 
			
		||||
        ConversationCollection: Whisper.ConversationCollection,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.reset(collection.models);
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -83,6 +83,42 @@
 | 
			
		|||
      this.on('unload', this.unload);
 | 
			
		||||
      this.on('expired', this.onExpired);
 | 
			
		||||
      this.setToExpire();
 | 
			
		||||
 | 
			
		||||
      // Keep props ready
 | 
			
		||||
      const generateProps = () => {
 | 
			
		||||
        if (this.isExpirationTimerUpdate()) {
 | 
			
		||||
          this.propsForTimerNotification = this.getPropsForTimerNotification();
 | 
			
		||||
        } else if (this.isKeyChange()) {
 | 
			
		||||
          this.propsForSafetyNumberNotification = this.getPropsForSafetyNumberNotification();
 | 
			
		||||
        } else if (this.isVerifiedChange()) {
 | 
			
		||||
          this.propsForVerificationNotification = this.getPropsForVerificationNotification();
 | 
			
		||||
        } else if (this.isEndSession()) {
 | 
			
		||||
          this.propsForResetSessionNotification = this.getPropsForResetSessionNotification();
 | 
			
		||||
        } else if (this.isGroupUpdate()) {
 | 
			
		||||
          this.propsForGroupNotification = this.getPropsForGroupNotification();
 | 
			
		||||
        } else {
 | 
			
		||||
          this.propsForSearchResult = this.getPropsForSearchResult();
 | 
			
		||||
          this.propsForMessage = this.getPropsForMessage();
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      this.on('change', generateProps);
 | 
			
		||||
 | 
			
		||||
      const applicableConversationChanges =
 | 
			
		||||
        'change:color change:name change:number change:profileName change:profileAvatar';
 | 
			
		||||
 | 
			
		||||
      const conversation = this.getConversation();
 | 
			
		||||
      const fromContact = this.getIncomingContact();
 | 
			
		||||
 | 
			
		||||
      this.listenTo(conversation, applicableConversationChanges, generateProps);
 | 
			
		||||
      if (fromContact) {
 | 
			
		||||
        this.listenTo(
 | 
			
		||||
          fromContact,
 | 
			
		||||
          applicableConversationChanges,
 | 
			
		||||
          generateProps
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      generateProps();
 | 
			
		||||
    },
 | 
			
		||||
    idForLogging() {
 | 
			
		||||
      return `${this.get('source')}.${this.get('sourceDevice')} ${this.get(
 | 
			
		||||
| 
						 | 
				
			
			@ -387,6 +423,35 @@
 | 
			
		|||
 | 
			
		||||
      return 'sending';
 | 
			
		||||
    },
 | 
			
		||||
    getPropsForSearchResult() {
 | 
			
		||||
      const fromNumber = this.getSource();
 | 
			
		||||
      const from = this.findAndFormatContact(fromNumber);
 | 
			
		||||
      if (fromNumber === this.OUR_NUMBER) {
 | 
			
		||||
        from.isMe = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const toNumber = this.get('conversationId');
 | 
			
		||||
      let to = this.findAndFormatContact(toNumber);
 | 
			
		||||
      if (toNumber === this.OUR_NUMBER) {
 | 
			
		||||
        to.isMe = true;
 | 
			
		||||
      } else if (fromNumber === toNumber) {
 | 
			
		||||
        to = {
 | 
			
		||||
          isMe: true,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        from,
 | 
			
		||||
        to,
 | 
			
		||||
 | 
			
		||||
        isSelected: this.isSelected,
 | 
			
		||||
 | 
			
		||||
        id: this.id,
 | 
			
		||||
        conversationId: this.get('conversationId'),
 | 
			
		||||
        receivedAt: this.get('received_at'),
 | 
			
		||||
        snippet: this.get('snippet'),
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    getPropsForMessage() {
 | 
			
		||||
      const phoneNumber = this.getSource();
 | 
			
		||||
      const contact = this.findAndFormatContact(phoneNumber);
 | 
			
		||||
| 
						 | 
				
			
			@ -495,7 +560,7 @@
 | 
			
		|||
      // Would be nice to do this before render, on initial load of message
 | 
			
		||||
      if (!window.isSignalAccountCheckComplete(firstNumber)) {
 | 
			
		||||
        window.checkForSignalAccount(firstNumber).then(() => {
 | 
			
		||||
          this.trigger('change');
 | 
			
		||||
          this.trigger('change', this);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -282,9 +282,9 @@ async function _finishJob(message, id) {
 | 
			
		|||
 | 
			
		||||
      if (fromConversation && message !== fromConversation) {
 | 
			
		||||
        fromConversation.set(message.attributes);
 | 
			
		||||
        fromConversation.trigger('change');
 | 
			
		||||
        fromConversation.trigger('change', fromConversation);
 | 
			
		||||
      } else {
 | 
			
		||||
        message.trigger('change');
 | 
			
		||||
        message.trigger('change', message);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								js/modules/data.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								js/modules/data.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
export function searchMessages(query: string): Promise<Array<any>>;
 | 
			
		||||
export function searchConversations(query: string): Promise<Array<any>>;
 | 
			
		||||
| 
						 | 
				
			
			@ -96,7 +96,10 @@ module.exports = {
 | 
			
		|||
  getAllConversationIds,
 | 
			
		||||
  getAllPrivateConversations,
 | 
			
		||||
  getAllGroupsInvolvingId,
 | 
			
		||||
 | 
			
		||||
  searchConversations,
 | 
			
		||||
  searchMessages,
 | 
			
		||||
  searchMessagesInConversation,
 | 
			
		||||
 | 
			
		||||
  getMessageCount,
 | 
			
		||||
  saveMessage,
 | 
			
		||||
| 
						 | 
				
			
			@ -624,12 +627,27 @@ async function getAllGroupsInvolvingId(id, { ConversationCollection }) {
 | 
			
		|||
  return collection;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function searchConversations(query, { ConversationCollection }) {
 | 
			
		||||
async function searchConversations(query) {
 | 
			
		||||
  const conversations = await channels.searchConversations(query);
 | 
			
		||||
  return conversations;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  const collection = new ConversationCollection();
 | 
			
		||||
  collection.add(conversations);
 | 
			
		||||
  return collection;
 | 
			
		||||
async function searchMessages(query, { limit } = {}) {
 | 
			
		||||
  const messages = await channels.searchMessages(query, { limit });
 | 
			
		||||
  return messages;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function searchMessagesInConversation(
 | 
			
		||||
  query,
 | 
			
		||||
  conversationId,
 | 
			
		||||
  { limit } = {}
 | 
			
		||||
) {
 | 
			
		||||
  const messages = await channels.searchMessagesInConversation(
 | 
			
		||||
    query,
 | 
			
		||||
    conversationId,
 | 
			
		||||
    { limit }
 | 
			
		||||
  );
 | 
			
		||||
  return messages;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Message
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -329,8 +329,11 @@ function isChunkSneaky(chunk) {
 | 
			
		|||
function isLinkSneaky(link) {
 | 
			
		||||
  const domain = getDomain(link);
 | 
			
		||||
 | 
			
		||||
  // This is necesary because getDomain returns domains in punycode form
 | 
			
		||||
  const unicodeDomain = nodeUrl.domainToUnicode(domain);
 | 
			
		||||
  // This is necesary because getDomain returns domains in punycode form. We check whether
 | 
			
		||||
  //   it's available for the StyleGuide.
 | 
			
		||||
  const unicodeDomain = nodeUrl.domainToUnicode
 | 
			
		||||
    ? nodeUrl.domainToUnicode(domain)
 | 
			
		||||
    : domain;
 | 
			
		||||
 | 
			
		||||
  const chunks = unicodeDomain.split('.');
 | 
			
		||||
  for (let i = 0, max = chunks.length; i < max; i += 1) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
// The idea with this file is to make it webpackable for the style guide
 | 
			
		||||
 | 
			
		||||
const { bindActionCreators } = require('redux');
 | 
			
		||||
const Backbone = require('../../ts/backbone');
 | 
			
		||||
const Crypto = require('./crypto');
 | 
			
		||||
const Data = require('./data');
 | 
			
		||||
| 
						 | 
				
			
			@ -29,9 +30,6 @@ const { ContactName } = require('../../ts/components/conversation/ContactName');
 | 
			
		|||
const {
 | 
			
		||||
  ConversationHeader,
 | 
			
		||||
} = require('../../ts/components/conversation/ConversationHeader');
 | 
			
		||||
const {
 | 
			
		||||
  ConversationListItem,
 | 
			
		||||
} = require('../../ts/components/ConversationListItem');
 | 
			
		||||
const {
 | 
			
		||||
  EmbeddedContact,
 | 
			
		||||
} = require('../../ts/components/conversation/EmbeddedContact');
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +42,6 @@ const { LightboxGallery } = require('../../ts/components/LightboxGallery');
 | 
			
		|||
const {
 | 
			
		||||
  MediaGallery,
 | 
			
		||||
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
 | 
			
		||||
const { MainHeader } = require('../../ts/components/MainHeader');
 | 
			
		||||
const { Message } = require('../../ts/components/conversation/Message');
 | 
			
		||||
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
 | 
			
		||||
const {
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +67,12 @@ const {
 | 
			
		|||
  VerificationNotification,
 | 
			
		||||
} = require('../../ts/components/conversation/VerificationNotification');
 | 
			
		||||
 | 
			
		||||
// State
 | 
			
		||||
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
 | 
			
		||||
const { createStore } = require('../../ts/state/createStore');
 | 
			
		||||
const conversationsDuck = require('../../ts/state/ducks/conversations');
 | 
			
		||||
const userDuck = require('../../ts/state/ducks/user');
 | 
			
		||||
 | 
			
		||||
// Migrations
 | 
			
		||||
const {
 | 
			
		||||
  getPlaceholderMigrations,
 | 
			
		||||
| 
						 | 
				
			
			@ -201,13 +204,11 @@ exports.setup = (options = {}) => {
 | 
			
		|||
    ContactListItem,
 | 
			
		||||
    ContactName,
 | 
			
		||||
    ConversationHeader,
 | 
			
		||||
    ConversationListItem,
 | 
			
		||||
    EmbeddedContact,
 | 
			
		||||
    Emojify,
 | 
			
		||||
    GroupNotification,
 | 
			
		||||
    Lightbox,
 | 
			
		||||
    LightboxGallery,
 | 
			
		||||
    MainHeader,
 | 
			
		||||
    MediaGallery,
 | 
			
		||||
    Message,
 | 
			
		||||
    MessageBody,
 | 
			
		||||
| 
						 | 
				
			
			@ -224,6 +225,20 @@ exports.setup = (options = {}) => {
 | 
			
		|||
    VerificationNotification,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const Roots = {
 | 
			
		||||
    createLeftPane,
 | 
			
		||||
  };
 | 
			
		||||
  const Ducks = {
 | 
			
		||||
    conversations: conversationsDuck,
 | 
			
		||||
    user: userDuck,
 | 
			
		||||
  };
 | 
			
		||||
  const State = {
 | 
			
		||||
    bindActionCreators,
 | 
			
		||||
    createStore,
 | 
			
		||||
    Roots,
 | 
			
		||||
    Ducks,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const Types = {
 | 
			
		||||
    Attachment: AttachmentType,
 | 
			
		||||
    Contact,
 | 
			
		||||
| 
						 | 
				
			
			@ -262,6 +277,7 @@ exports.setup = (options = {}) => {
 | 
			
		|||
    OS,
 | 
			
		||||
    RefreshSenderCertificate,
 | 
			
		||||
    Settings,
 | 
			
		||||
    State,
 | 
			
		||||
    Types,
 | 
			
		||||
    Util,
 | 
			
		||||
    Views,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,7 +20,7 @@ const {
 | 
			
		|||
//   contentType: MIMEType
 | 
			
		||||
//   data: ArrayBuffer
 | 
			
		||||
//   digest: ArrayBuffer
 | 
			
		||||
//   fileName: string | null
 | 
			
		||||
//   fileName?: string
 | 
			
		||||
//   flags: null
 | 
			
		||||
//   key: ArrayBuffer
 | 
			
		||||
//   size: integer
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,10 +39,6 @@
 | 
			
		|||
      this.fastUpdate = this.update;
 | 
			
		||||
      this.update = _.debounce(this.update, 1000);
 | 
			
		||||
    },
 | 
			
		||||
    onClick(conversationId) {
 | 
			
		||||
      const conversation = ConversationController.get(conversationId);
 | 
			
		||||
      this.trigger('click', conversation);
 | 
			
		||||
    },
 | 
			
		||||
    update() {
 | 
			
		||||
      if (this.lastNotification) {
 | 
			
		||||
        this.lastNotification.close();
 | 
			
		||||
| 
						 | 
				
			
			@ -148,7 +144,8 @@
 | 
			
		|||
        tag: isNotificationGroupingSupported ? 'signal' : undefined,
 | 
			
		||||
        silent: !status.shouldPlayNotificationSound,
 | 
			
		||||
      });
 | 
			
		||||
      notification.onclick = () => this.onClick(last.conversationId);
 | 
			
		||||
      notification.onclick = () =>
 | 
			
		||||
        this.trigger('click', last.conversationId, last.id);
 | 
			
		||||
      this.lastNotification = notification;
 | 
			
		||||
 | 
			
		||||
      // We continue to build up more and more messages for our notifications
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -171,10 +171,10 @@
 | 
			
		|||
        view.onProgress(count);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    openConversation(conversation) {
 | 
			
		||||
      if (conversation) {
 | 
			
		||||
    openConversation(id, messageId) {
 | 
			
		||||
      if (id) {
 | 
			
		||||
        this.openInbox().then(() => {
 | 
			
		||||
          this.inboxView.openConversation(conversation);
 | 
			
		||||
          this.inboxView.openConversation(id, messageId);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,230 +0,0 @@
 | 
			
		|||
/* global $: false */
 | 
			
		||||
/* global _: false */
 | 
			
		||||
/* global Backbone: false */
 | 
			
		||||
/* global filesize: false */
 | 
			
		||||
 | 
			
		||||
/* global i18n: false */
 | 
			
		||||
/* global Signal: false */
 | 
			
		||||
/* global Whisper: false */
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
  'use strict';
 | 
			
		||||
 | 
			
		||||
  const FileView = Whisper.View.extend({
 | 
			
		||||
    tagName: 'div',
 | 
			
		||||
    className: 'fileView',
 | 
			
		||||
    templateName: 'file-view',
 | 
			
		||||
    render_attributes() {
 | 
			
		||||
      return this.model;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const ImageView = Backbone.View.extend({
 | 
			
		||||
    tagName: 'img',
 | 
			
		||||
    initialize(blobUrl) {
 | 
			
		||||
      this.blobUrl = blobUrl;
 | 
			
		||||
    },
 | 
			
		||||
    events: {
 | 
			
		||||
      load: 'update',
 | 
			
		||||
    },
 | 
			
		||||
    update() {
 | 
			
		||||
      this.trigger('update');
 | 
			
		||||
    },
 | 
			
		||||
    render() {
 | 
			
		||||
      this.$el.attr('src', this.blobUrl);
 | 
			
		||||
      return this;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const MediaView = Backbone.View.extend({
 | 
			
		||||
    initialize(dataUrl, { contentType } = {}) {
 | 
			
		||||
      this.dataUrl = dataUrl;
 | 
			
		||||
      this.contentType = contentType;
 | 
			
		||||
      this.$el.attr('controls', '');
 | 
			
		||||
    },
 | 
			
		||||
    events: {
 | 
			
		||||
      canplay: 'canplay',
 | 
			
		||||
    },
 | 
			
		||||
    canplay() {
 | 
			
		||||
      this.trigger('update');
 | 
			
		||||
    },
 | 
			
		||||
    render() {
 | 
			
		||||
      const $el = $('<source>');
 | 
			
		||||
      $el.attr('src', this.dataUrl);
 | 
			
		||||
      this.$el.append($el);
 | 
			
		||||
      return this;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const AudioView = MediaView.extend({ tagName: 'audio' });
 | 
			
		||||
  const VideoView = MediaView.extend({ tagName: 'video' });
 | 
			
		||||
 | 
			
		||||
  // Blacklist common file types known to be unsupported in Chrome
 | 
			
		||||
  const unsupportedFileTypes = ['audio/aiff', 'video/quicktime'];
 | 
			
		||||
 | 
			
		||||
  Whisper.AttachmentView = Backbone.View.extend({
 | 
			
		||||
    tagName: 'div',
 | 
			
		||||
    className() {
 | 
			
		||||
      if (this.isImage()) {
 | 
			
		||||
        return 'attachment';
 | 
			
		||||
      }
 | 
			
		||||
      return 'attachment bubbled';
 | 
			
		||||
    },
 | 
			
		||||
    initialize(options) {
 | 
			
		||||
      this.blob = new Blob([this.model.data], { type: this.model.contentType });
 | 
			
		||||
      if (!this.model.size) {
 | 
			
		||||
        this.model.size = this.model.data.byteLength;
 | 
			
		||||
      }
 | 
			
		||||
      if (options.timestamp) {
 | 
			
		||||
        this.timestamp = options.timestamp;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    events: {
 | 
			
		||||
      click: 'onClick',
 | 
			
		||||
    },
 | 
			
		||||
    unload() {
 | 
			
		||||
      this.blob = null;
 | 
			
		||||
 | 
			
		||||
      if (this.lightboxView) {
 | 
			
		||||
        this.lightboxView.remove();
 | 
			
		||||
      }
 | 
			
		||||
      if (this.fileView) {
 | 
			
		||||
        this.fileView.remove();
 | 
			
		||||
      }
 | 
			
		||||
      if (this.view) {
 | 
			
		||||
        this.view.remove();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.remove();
 | 
			
		||||
    },
 | 
			
		||||
    onClick() {
 | 
			
		||||
      if (!this.isImage()) {
 | 
			
		||||
        this.saveFile();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const props = {
 | 
			
		||||
        objectURL: this.objectUrl,
 | 
			
		||||
        contentType: this.model.contentType,
 | 
			
		||||
        onSave: () => this.saveFile(),
 | 
			
		||||
        // implicit: `close`
 | 
			
		||||
      };
 | 
			
		||||
      this.lightboxView = new Whisper.ReactWrapperView({
 | 
			
		||||
        Component: Signal.Components.Lightbox,
 | 
			
		||||
        props,
 | 
			
		||||
        onClose: () => Signal.Backbone.Views.Lightbox.hide(),
 | 
			
		||||
      });
 | 
			
		||||
      Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
 | 
			
		||||
    },
 | 
			
		||||
    isVoiceMessage() {
 | 
			
		||||
      return Signal.Types.Attachment.isVoiceMessage(this.model);
 | 
			
		||||
    },
 | 
			
		||||
    isAudio() {
 | 
			
		||||
      const { contentType } = this.model;
 | 
			
		||||
      // TODO: Implement and use `Signal.Util.GoogleChrome.isAudioTypeSupported`:
 | 
			
		||||
      return Signal.Types.MIME.isAudio(contentType);
 | 
			
		||||
    },
 | 
			
		||||
    isVideo() {
 | 
			
		||||
      const { contentType } = this.model;
 | 
			
		||||
      return Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
 | 
			
		||||
    },
 | 
			
		||||
    isImage() {
 | 
			
		||||
      const { contentType } = this.model;
 | 
			
		||||
      return Signal.Util.GoogleChrome.isImageTypeSupported(contentType);
 | 
			
		||||
    },
 | 
			
		||||
    mediaType() {
 | 
			
		||||
      if (this.isVoiceMessage()) {
 | 
			
		||||
        return 'voice';
 | 
			
		||||
      } else if (this.isAudio()) {
 | 
			
		||||
        return 'audio';
 | 
			
		||||
      } else if (this.isVideo()) {
 | 
			
		||||
        return 'video';
 | 
			
		||||
      } else if (this.isImage()) {
 | 
			
		||||
        return 'image';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // NOTE: The existing code had no `return` but ESLint insists. Thought
 | 
			
		||||
      // about throwing an error assuming this was unreachable code but it turns
 | 
			
		||||
      // out that content type `image/tiff` falls through here:
 | 
			
		||||
      return undefined;
 | 
			
		||||
    },
 | 
			
		||||
    displayName() {
 | 
			
		||||
      if (this.isVoiceMessage()) {
 | 
			
		||||
        return i18n('voiceMessage');
 | 
			
		||||
      }
 | 
			
		||||
      if (this.model.fileName) {
 | 
			
		||||
        return this.model.fileName;
 | 
			
		||||
      }
 | 
			
		||||
      if (this.isAudio() || this.isVideo()) {
 | 
			
		||||
        return i18n('mediaMessage');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return i18n('unnamedFile');
 | 
			
		||||
    },
 | 
			
		||||
    saveFile() {
 | 
			
		||||
      Signal.Types.Attachment.save({
 | 
			
		||||
        attachment: this.model,
 | 
			
		||||
        document,
 | 
			
		||||
        getAbsolutePath: Signal.Migrations.getAbsoluteAttachmentPath,
 | 
			
		||||
        timestamp: this.timestamp,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    render() {
 | 
			
		||||
      if (!this.isImage()) {
 | 
			
		||||
        this.renderFileView();
 | 
			
		||||
      }
 | 
			
		||||
      let View;
 | 
			
		||||
      if (this.isImage()) {
 | 
			
		||||
        View = ImageView;
 | 
			
		||||
      } else if (this.isAudio()) {
 | 
			
		||||
        View = AudioView;
 | 
			
		||||
      } else if (this.isVideo()) {
 | 
			
		||||
        View = VideoView;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!View || _.contains(unsupportedFileTypes, this.model.contentType)) {
 | 
			
		||||
        this.update();
 | 
			
		||||
        return this;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!this.objectUrl) {
 | 
			
		||||
        this.objectUrl = window.URL.createObjectURL(this.blob);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const { blob } = this;
 | 
			
		||||
      const { contentType } = this.model;
 | 
			
		||||
      this.view = new View(this.objectUrl, { blob, contentType });
 | 
			
		||||
      this.view.$el.appendTo(this.$el);
 | 
			
		||||
      this.listenTo(this.view, 'update', this.update);
 | 
			
		||||
      this.view.render();
 | 
			
		||||
      if (View !== ImageView) {
 | 
			
		||||
        this.timeout = setTimeout(this.onTimeout.bind(this), 5000);
 | 
			
		||||
      }
 | 
			
		||||
      return this;
 | 
			
		||||
    },
 | 
			
		||||
    onTimeout() {
 | 
			
		||||
      // Image or media element failed to load. Fall back to FileView.
 | 
			
		||||
      this.stopListening(this.view);
 | 
			
		||||
      this.update();
 | 
			
		||||
    },
 | 
			
		||||
    renderFileView() {
 | 
			
		||||
      this.fileView = new FileView({
 | 
			
		||||
        model: {
 | 
			
		||||
          mediaType: this.mediaType(),
 | 
			
		||||
          fileName: this.displayName(),
 | 
			
		||||
          fileSize: filesize(this.model.size),
 | 
			
		||||
          altText: i18n('clickToSave'),
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.fileView.$el.appendTo(this.$el.empty());
 | 
			
		||||
      this.fileView.render();
 | 
			
		||||
      return this;
 | 
			
		||||
    },
 | 
			
		||||
    update() {
 | 
			
		||||
      clearTimeout(this.timeout);
 | 
			
		||||
      this.trigger('update');
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -1,51 +0,0 @@
 | 
			
		|||
/* global Whisper, Signal, Backbone */
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
  'use strict';
 | 
			
		||||
 | 
			
		||||
  window.Whisper = window.Whisper || {};
 | 
			
		||||
 | 
			
		||||
  // list of conversations, showing user/group and last message sent
 | 
			
		||||
  Whisper.ConversationListItemView = Whisper.View.extend({
 | 
			
		||||
    tagName: 'div',
 | 
			
		||||
    className() {
 | 
			
		||||
      return `conversation-list-item contact ${this.model.cid}`;
 | 
			
		||||
    },
 | 
			
		||||
    templateName: 'conversation-preview',
 | 
			
		||||
    initialize() {
 | 
			
		||||
      this.listenTo(this.model, 'destroy', this.remove);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    remove() {
 | 
			
		||||
      if (this.childView) {
 | 
			
		||||
        this.childView.remove();
 | 
			
		||||
        this.childView = null;
 | 
			
		||||
      }
 | 
			
		||||
      Backbone.View.prototype.remove.call(this);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
      if (this.childView) {
 | 
			
		||||
        this.childView.remove();
 | 
			
		||||
        this.childView = null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const props = this.model.getPropsForListItem();
 | 
			
		||||
      this.childView = new Whisper.ReactWrapperView({
 | 
			
		||||
        className: 'list-item-wrapper',
 | 
			
		||||
        Component: Signal.Components.ConversationListItem,
 | 
			
		||||
        props,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const update = () =>
 | 
			
		||||
        this.childView.update(this.model.getPropsForListItem());
 | 
			
		||||
 | 
			
		||||
      this.listenTo(this.model, 'change', update);
 | 
			
		||||
 | 
			
		||||
      this.$el.append(this.childView.el);
 | 
			
		||||
 | 
			
		||||
      return this;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -1,68 +0,0 @@
 | 
			
		|||
/* global Whisper, getInboxCollection, $ */
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
  'use strict';
 | 
			
		||||
 | 
			
		||||
  window.Whisper = window.Whisper || {};
 | 
			
		||||
 | 
			
		||||
  Whisper.ConversationListView = Whisper.ListView.extend({
 | 
			
		||||
    tagName: 'div',
 | 
			
		||||
    itemView: Whisper.ConversationListItemView,
 | 
			
		||||
    updateLocation(conversation) {
 | 
			
		||||
      const $el = this.$(`.${conversation.cid}`);
 | 
			
		||||
 | 
			
		||||
      if (!$el || !$el.length) {
 | 
			
		||||
        window.log.warn(
 | 
			
		||||
          'updateLocation: did not find element for conversation',
 | 
			
		||||
          conversation.idForLogging()
 | 
			
		||||
        );
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if ($el.length > 1) {
 | 
			
		||||
        window.log.warn(
 | 
			
		||||
          'updateLocation: found more than one element for conversation',
 | 
			
		||||
          conversation.idForLogging()
 | 
			
		||||
        );
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const $allConversations = this.$('.conversation-list-item');
 | 
			
		||||
      const inboxCollection = getInboxCollection();
 | 
			
		||||
      const index = inboxCollection.indexOf(conversation);
 | 
			
		||||
 | 
			
		||||
      const elIndex = $allConversations.index($el);
 | 
			
		||||
      if (elIndex < 0) {
 | 
			
		||||
        window.log.warn(
 | 
			
		||||
          'updateLocation: did not find index for conversation',
 | 
			
		||||
          conversation.idForLogging()
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (index === elIndex) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (index === 0) {
 | 
			
		||||
        this.$el.prepend($el);
 | 
			
		||||
      } else if (index === this.collection.length - 1) {
 | 
			
		||||
        this.$el.append($el);
 | 
			
		||||
      } else {
 | 
			
		||||
        const targetConversation = inboxCollection.at(index - 1);
 | 
			
		||||
        const target = this.$(`.${targetConversation.cid}`);
 | 
			
		||||
        $el.insertAfter(target);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if ($('.selected').length) {
 | 
			
		||||
        $('.selected')[0].scrollIntoView({
 | 
			
		||||
          block: 'nearest',
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    removeItem(conversation) {
 | 
			
		||||
      const $el = this.$(`.${conversation.cid}`);
 | 
			
		||||
      if ($el && $el.length > 0) {
 | 
			
		||||
        $el.remove();
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -1,171 +0,0 @@
 | 
			
		|||
/* global ConversationController, i18n, textsecure, Whisper */
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
  'use strict';
 | 
			
		||||
 | 
			
		||||
  window.Whisper = window.Whisper || {};
 | 
			
		||||
 | 
			
		||||
  const isSearchable = conversation => conversation.isSearchable();
 | 
			
		||||
 | 
			
		||||
  Whisper.NewContactView = Whisper.View.extend({
 | 
			
		||||
    templateName: 'new-contact',
 | 
			
		||||
    className: 'conversation-list-item contact',
 | 
			
		||||
    events: {
 | 
			
		||||
      click: 'validate',
 | 
			
		||||
    },
 | 
			
		||||
    initialize() {
 | 
			
		||||
      this.listenTo(this.model, 'change', this.render); // auto update
 | 
			
		||||
    },
 | 
			
		||||
    render_attributes() {
 | 
			
		||||
      return {
 | 
			
		||||
        number: i18n('startConversation'),
 | 
			
		||||
        title: this.model.getNumber(),
 | 
			
		||||
        avatar: this.model.getAvatar(),
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    validate() {
 | 
			
		||||
      if (this.model.isValid()) {
 | 
			
		||||
        this.$el.addClass('valid');
 | 
			
		||||
      } else {
 | 
			
		||||
        this.$el.removeClass('valid');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Whisper.ConversationSearchView = Whisper.View.extend({
 | 
			
		||||
    className: 'conversation-search',
 | 
			
		||||
    initialize(options) {
 | 
			
		||||
      this.$input = options.input;
 | 
			
		||||
      this.$new_contact = this.$('.new-contact');
 | 
			
		||||
 | 
			
		||||
      this.typeahead = new Whisper.ConversationCollection();
 | 
			
		||||
      this.collection = new Whisper.ConversationCollection([], {
 | 
			
		||||
        comparator(m) {
 | 
			
		||||
          return m.getTitle().toLowerCase();
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(this.collection, 'select', conversation => {
 | 
			
		||||
        this.resetTypeahead();
 | 
			
		||||
        this.trigger('open', conversation);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // View to display the matched contacts from typeahead
 | 
			
		||||
      this.typeahead_view = new Whisper.ConversationListView({
 | 
			
		||||
        collection: this.collection,
 | 
			
		||||
      });
 | 
			
		||||
      this.$el.append(this.typeahead_view.el);
 | 
			
		||||
      this.initNewContact();
 | 
			
		||||
      this.pending = Promise.resolve();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    events: {
 | 
			
		||||
      'click .new-contact': 'createConversation',
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    filterContacts() {
 | 
			
		||||
      const query = this.$input.val().trim();
 | 
			
		||||
      if (query.length) {
 | 
			
		||||
        if (this.maybeNumber(query)) {
 | 
			
		||||
          this.new_contact_view.model.set('id', query);
 | 
			
		||||
          this.new_contact_view.render().$el.show();
 | 
			
		||||
          this.new_contact_view.validate();
 | 
			
		||||
          this.hideHints();
 | 
			
		||||
        } else {
 | 
			
		||||
          this.new_contact_view.$el.hide();
 | 
			
		||||
        }
 | 
			
		||||
        // NOTE: Temporarily allow `then` until we convert the entire file
 | 
			
		||||
        // to `async` / `await`:
 | 
			
		||||
        /* eslint-disable more/no-then */
 | 
			
		||||
        this.pending = this.pending.then(() =>
 | 
			
		||||
          this.typeahead.search(query).then(() => {
 | 
			
		||||
            let results = this.typeahead.filter(isSearchable);
 | 
			
		||||
            const noteToSelf = i18n('noteToSelf');
 | 
			
		||||
            if (noteToSelf.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
 | 
			
		||||
              const ourNumber = textsecure.storage.user.getNumber();
 | 
			
		||||
              const conversation = ConversationController.get(ourNumber);
 | 
			
		||||
              if (conversation) {
 | 
			
		||||
                // ensure that we don't have duplicates in our results
 | 
			
		||||
                results = results.filter(item => item.id !== ourNumber);
 | 
			
		||||
                results.unshift(conversation);
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.typeahead_view.collection.reset(results);
 | 
			
		||||
          })
 | 
			
		||||
        );
 | 
			
		||||
        /* eslint-enable more/no-then */
 | 
			
		||||
        this.trigger('show');
 | 
			
		||||
      } else {
 | 
			
		||||
        this.resetTypeahead();
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    initNewContact() {
 | 
			
		||||
      if (this.new_contact_view) {
 | 
			
		||||
        this.new_contact_view.undelegateEvents();
 | 
			
		||||
        this.new_contact_view.$el.hide();
 | 
			
		||||
      }
 | 
			
		||||
      const model = new Whisper.Conversation({ type: 'private' });
 | 
			
		||||
      this.new_contact_view = new Whisper.NewContactView({
 | 
			
		||||
        el: this.$new_contact,
 | 
			
		||||
        model,
 | 
			
		||||
      }).render();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async createConversation() {
 | 
			
		||||
      const isValidNumber = this.new_contact_view.model.isValid();
 | 
			
		||||
      if (!isValidNumber) {
 | 
			
		||||
        this.new_contact_view.$('.number').text(i18n('invalidNumberError'));
 | 
			
		||||
        this.$input.focus();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const newConversationId = this.new_contact_view.model.id;
 | 
			
		||||
      const conversation = await ConversationController.getOrCreateAndWait(
 | 
			
		||||
        newConversationId,
 | 
			
		||||
        'private'
 | 
			
		||||
      );
 | 
			
		||||
      this.trigger('open', conversation);
 | 
			
		||||
      this.initNewContact();
 | 
			
		||||
      this.resetTypeahead();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    reset() {
 | 
			
		||||
      this.delegateEvents();
 | 
			
		||||
      this.typeahead_view.delegateEvents();
 | 
			
		||||
      this.new_contact_view.delegateEvents();
 | 
			
		||||
      this.resetTypeahead();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    resetTypeahead() {
 | 
			
		||||
      this.hideHints();
 | 
			
		||||
      this.new_contact_view.$el.hide();
 | 
			
		||||
      this.$input.val('').focus();
 | 
			
		||||
      this.typeahead_view.collection.reset([]);
 | 
			
		||||
      this.trigger('hide');
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    showHints() {
 | 
			
		||||
      if (!this.hintView) {
 | 
			
		||||
        this.hintView = new Whisper.HintView({
 | 
			
		||||
          className: 'contact placeholder',
 | 
			
		||||
          content: i18n('newPhoneNumber'),
 | 
			
		||||
        }).render();
 | 
			
		||||
        this.hintView.$el.insertAfter(this.$input);
 | 
			
		||||
      }
 | 
			
		||||
      this.hintView.$el.show();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    hideHints() {
 | 
			
		||||
      if (this.hintView) {
 | 
			
		||||
        this.hintView.remove();
 | 
			
		||||
        this.hintView = null;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    maybeNumber(number) {
 | 
			
		||||
      return number.replace(/[\s-.()]*/g, '').match(/^\+?[0-9]*$/);
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -1377,11 +1377,7 @@
 | 
			
		|||
    },
 | 
			
		||||
 | 
			
		||||
    async openConversation(number) {
 | 
			
		||||
      const conversation = await window.ConversationController.getOrCreateAndWait(
 | 
			
		||||
        number,
 | 
			
		||||
        'private'
 | 
			
		||||
      );
 | 
			
		||||
      window.Whisper.events.trigger('showConversation', conversation);
 | 
			
		||||
      window.Whisper.events.trigger('showConversation', number);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    listenBack(view) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,18 +0,0 @@
 | 
			
		|||
/* global Whisper */
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
  'use strict';
 | 
			
		||||
 | 
			
		||||
  window.Whisper = window.Whisper || {};
 | 
			
		||||
 | 
			
		||||
  Whisper.HintView = Whisper.View.extend({
 | 
			
		||||
    templateName: 'hint',
 | 
			
		||||
    initialize(options) {
 | 
			
		||||
      this.content = options.content;
 | 
			
		||||
    },
 | 
			
		||||
    render_attributes() {
 | 
			
		||||
      return { content: this.content };
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,12 @@
 | 
			
		|||
/* global ConversationController: false */
 | 
			
		||||
/* global extension: false */
 | 
			
		||||
/* global getInboxCollection: false */
 | 
			
		||||
/* global i18n: false */
 | 
			
		||||
/* global Whisper: false */
 | 
			
		||||
/* global textsecure: false */
 | 
			
		||||
/* global Signal: false */
 | 
			
		||||
/* global
 | 
			
		||||
  ConversationController,
 | 
			
		||||
  extension,
 | 
			
		||||
  getInboxCollection,
 | 
			
		||||
  i18n,
 | 
			
		||||
  Whisper,
 | 
			
		||||
  textsecure,
 | 
			
		||||
  Signal
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
| 
						 | 
				
			
			@ -38,38 +40,6 @@
 | 
			
		|||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Whisper.FontSizeView = Whisper.View.extend({
 | 
			
		||||
    defaultSize: 14,
 | 
			
		||||
    maxSize: 30,
 | 
			
		||||
    minSize: 14,
 | 
			
		||||
    initialize() {
 | 
			
		||||
      this.currentSize = this.defaultSize;
 | 
			
		||||
      this.render();
 | 
			
		||||
    },
 | 
			
		||||
    events: { keydown: 'zoomText' },
 | 
			
		||||
    zoomText(e) {
 | 
			
		||||
      if (!e.ctrlKey) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const keyCode = e.which || e.keyCode;
 | 
			
		||||
      const maxSize = 22; // if bigger text goes outside send-message textarea
 | 
			
		||||
      const minSize = 14;
 | 
			
		||||
      if (keyCode === 189 || keyCode === 109) {
 | 
			
		||||
        if (this.currentSize > minSize) {
 | 
			
		||||
          this.currentSize -= 1;
 | 
			
		||||
        }
 | 
			
		||||
      } else if (keyCode === 187 || keyCode === 107) {
 | 
			
		||||
        if (this.currentSize < maxSize) {
 | 
			
		||||
          this.currentSize += 1;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      this.render();
 | 
			
		||||
    },
 | 
			
		||||
    render() {
 | 
			
		||||
      this.$el.css('font-size', `${this.currentSize}px`);
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Whisper.AppLoadingScreen = Whisper.View.extend({
 | 
			
		||||
    templateName: 'app-loading-screen',
 | 
			
		||||
    className: 'app-loading-screen',
 | 
			
		||||
| 
						 | 
				
			
			@ -92,20 +62,6 @@
 | 
			
		|||
      this.render();
 | 
			
		||||
      this.$el.attr('tabindex', '1');
 | 
			
		||||
 | 
			
		||||
      // eslint-disable-next-line no-new
 | 
			
		||||
      new Whisper.FontSizeView({ el: this.$el });
 | 
			
		||||
 | 
			
		||||
      const ourNumber = textsecure.storage.user.getNumber();
 | 
			
		||||
      const me = ConversationController.getOrCreate(ourNumber, 'private');
 | 
			
		||||
      this.mainHeaderView = new Whisper.ReactWrapperView({
 | 
			
		||||
        className: 'main-header-wrapper',
 | 
			
		||||
        Component: Signal.Components.MainHeader,
 | 
			
		||||
        props: me.format(),
 | 
			
		||||
      });
 | 
			
		||||
      const update = () => this.mainHeaderView.update(me.format());
 | 
			
		||||
      this.listenTo(me, 'change', update);
 | 
			
		||||
      this.$('.main-header-placeholder').append(this.mainHeaderView.el);
 | 
			
		||||
 | 
			
		||||
      this.conversation_stack = new Whisper.ConversationStack({
 | 
			
		||||
        el: this.$('.conversation-stack'),
 | 
			
		||||
        model: { window: options.window },
 | 
			
		||||
| 
						 | 
				
			
			@ -125,40 +81,6 @@
 | 
			
		|||
          this.networkStatusView.render();
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(inboxCollection, 'select', this.openConversation);
 | 
			
		||||
 | 
			
		||||
      this.inboxListView = new Whisper.ConversationListView({
 | 
			
		||||
        el: this.$('.inbox'),
 | 
			
		||||
        collection: inboxCollection,
 | 
			
		||||
      }).render();
 | 
			
		||||
 | 
			
		||||
      this.inboxListView.listenTo(
 | 
			
		||||
        inboxCollection,
 | 
			
		||||
        'add change:timestamp change:name change:number',
 | 
			
		||||
        this.inboxListView.updateLocation
 | 
			
		||||
      );
 | 
			
		||||
      this.inboxListView.listenTo(
 | 
			
		||||
        inboxCollection,
 | 
			
		||||
        'remove',
 | 
			
		||||
        this.inboxListView.removeItem
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      this.searchView = new Whisper.ConversationSearchView({
 | 
			
		||||
        el: this.$('.search-results'),
 | 
			
		||||
        input: this.$('input.search'),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.searchView.$el.hide();
 | 
			
		||||
 | 
			
		||||
      this.listenTo(this.searchView, 'hide', function toggleVisibility() {
 | 
			
		||||
        this.searchView.$el.hide();
 | 
			
		||||
        this.inboxListView.$el.show();
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(this.searchView, 'show', function toggleVisibility() {
 | 
			
		||||
        this.searchView.$el.show();
 | 
			
		||||
        this.inboxListView.$el.hide();
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(this.searchView, 'open', this.openConversation);
 | 
			
		||||
 | 
			
		||||
      this.networkStatusView = new Whisper.NetworkStatusView();
 | 
			
		||||
      this.$el
 | 
			
		||||
| 
						 | 
				
			
			@ -170,18 +92,78 @@
 | 
			
		|||
        banner.$el.prependTo(this.$el);
 | 
			
		||||
        this.$el.addClass('expired');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.setupLeftPane();
 | 
			
		||||
    },
 | 
			
		||||
    render_attributes: {
 | 
			
		||||
      welcomeToSignal: i18n('welcomeToSignal'),
 | 
			
		||||
      selectAContact: i18n('selectAContact'),
 | 
			
		||||
      searchForPeopleOrGroups: i18n('searchForPeopleOrGroups'),
 | 
			
		||||
      settings: i18n('settings'),
 | 
			
		||||
    },
 | 
			
		||||
    events: {
 | 
			
		||||
      click: 'onClick',
 | 
			
		||||
      'click #header': 'focusHeader',
 | 
			
		||||
      'click .conversation': 'focusConversation',
 | 
			
		||||
      'input input.search': 'filterContacts',
 | 
			
		||||
    },
 | 
			
		||||
    setupLeftPane() {
 | 
			
		||||
      // Here we set up a full redux store with initial state for our LeftPane Root
 | 
			
		||||
      const inboxCollection = getInboxCollection();
 | 
			
		||||
      const conversations = inboxCollection.map(
 | 
			
		||||
        conversation => conversation.cachedProps
 | 
			
		||||
      );
 | 
			
		||||
      const initialState = {
 | 
			
		||||
        conversations: {
 | 
			
		||||
          conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
 | 
			
		||||
        },
 | 
			
		||||
        user: {
 | 
			
		||||
          regionCode: window.storage.get('regionCode'),
 | 
			
		||||
          ourNumber: textsecure.storage.user.getNumber(),
 | 
			
		||||
          i18n: window.i18n,
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      this.store = Signal.State.createStore(initialState);
 | 
			
		||||
      window.inboxStore = this.store;
 | 
			
		||||
      this.leftPaneView = new Whisper.ReactWrapperView({
 | 
			
		||||
        JSX: Signal.State.Roots.createLeftPane(this.store),
 | 
			
		||||
        className: 'left-pane-wrapper',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Enables our redux store to be updated by backbone events in the outside world
 | 
			
		||||
      const {
 | 
			
		||||
        conversationAdded,
 | 
			
		||||
        conversationChanged,
 | 
			
		||||
        conversationRemoved,
 | 
			
		||||
        removeAllConversations,
 | 
			
		||||
        messageExpired,
 | 
			
		||||
        openConversationExternal,
 | 
			
		||||
      } = Signal.State.bindActionCreators(
 | 
			
		||||
        Signal.State.Ducks.conversations.actions,
 | 
			
		||||
        this.store.dispatch
 | 
			
		||||
      );
 | 
			
		||||
      const { userChanged } = Signal.State.bindActionCreators(
 | 
			
		||||
        Signal.State.Ducks.user.actions,
 | 
			
		||||
        this.store.dispatch
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      this.openConversationAction = openConversationExternal;
 | 
			
		||||
 | 
			
		||||
      this.listenTo(inboxCollection, 'remove', conversation => {
 | 
			
		||||
        const { id } = conversation || {};
 | 
			
		||||
        conversationRemoved(id);
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(inboxCollection, 'add', conversation => {
 | 
			
		||||
        const { id, cachedProps } = conversation || {};
 | 
			
		||||
        conversationAdded(id, cachedProps);
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(inboxCollection, 'change', conversation => {
 | 
			
		||||
        const { id, cachedProps } = conversation || {};
 | 
			
		||||
        conversationChanged(id, cachedProps);
 | 
			
		||||
      });
 | 
			
		||||
      this.listenTo(inboxCollection, 'reset', removeAllConversations);
 | 
			
		||||
 | 
			
		||||
      Whisper.events.on('messageExpired', messageExpired);
 | 
			
		||||
      Whisper.events.on('userChanged', userChanged);
 | 
			
		||||
 | 
			
		||||
      // Finally, add it to the DOM
 | 
			
		||||
      this.$('.left-pane-placeholder').append(this.leftPaneView.el);
 | 
			
		||||
    },
 | 
			
		||||
    startConnectionListener() {
 | 
			
		||||
      this.interval = setInterval(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -237,30 +219,18 @@
 | 
			
		|||
    reloadBackgroundPage() {
 | 
			
		||||
      window.location.reload();
 | 
			
		||||
    },
 | 
			
		||||
    filterContacts(e) {
 | 
			
		||||
      this.searchView.filterContacts(e);
 | 
			
		||||
      const input = this.$('input.search');
 | 
			
		||||
      if (input.val().length > 0) {
 | 
			
		||||
        input.addClass('active');
 | 
			
		||||
        const textDir = window.getComputedStyle(input[0]).direction;
 | 
			
		||||
        if (textDir === 'ltr') {
 | 
			
		||||
          input.removeClass('rtl').addClass('ltr');
 | 
			
		||||
        } else if (textDir === 'rtl') {
 | 
			
		||||
          input.removeClass('ltr').addClass('rtl');
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        input.removeClass('active');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    openConversation(conversation) {
 | 
			
		||||
      this.searchView.hideHints();
 | 
			
		||||
      if (conversation) {
 | 
			
		||||
        ConversationController.markAsSelected(conversation);
 | 
			
		||||
        this.conversation_stack.open(
 | 
			
		||||
          ConversationController.get(conversation.id)
 | 
			
		||||
    async openConversation(id, messageId) {
 | 
			
		||||
      const conversation = await window.ConversationController.getOrCreateAndWait(
 | 
			
		||||
        id,
 | 
			
		||||
        'private'
 | 
			
		||||
      );
 | 
			
		||||
        this.focusConversation();
 | 
			
		||||
 | 
			
		||||
      if (this.openConversationAction) {
 | 
			
		||||
        this.openConversationAction(id, messageId);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.conversation_stack.open(conversation);
 | 
			
		||||
      this.focusConversation();
 | 
			
		||||
    },
 | 
			
		||||
    closeRecording(e) {
 | 
			
		||||
      if (e && this.$(e.target).closest('.capture-audio').length > 0) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,36 +44,36 @@
 | 
			
		|||
    getRenderInfo() {
 | 
			
		||||
      const { Components } = window.Signal;
 | 
			
		||||
 | 
			
		||||
      if (this.model.isExpirationTimerUpdate()) {
 | 
			
		||||
      if (this.model.propsForTimerNotification) {
 | 
			
		||||
        return {
 | 
			
		||||
          Component: Components.TimerNotification,
 | 
			
		||||
          props: this.model.getPropsForTimerNotification(),
 | 
			
		||||
          props: this.model.propsForTimerNotification,
 | 
			
		||||
        };
 | 
			
		||||
      } else if (this.model.isKeyChange()) {
 | 
			
		||||
      } else if (this.model.propsForSafetyNumberNotification) {
 | 
			
		||||
        return {
 | 
			
		||||
          Component: Components.SafetyNumberNotification,
 | 
			
		||||
          props: this.model.getPropsForSafetyNumberNotification(),
 | 
			
		||||
          props: this.model.propsForSafetyNumberNotification,
 | 
			
		||||
        };
 | 
			
		||||
      } else if (this.model.isVerifiedChange()) {
 | 
			
		||||
      } else if (this.model.propsForVerificationNotification) {
 | 
			
		||||
        return {
 | 
			
		||||
          Component: Components.VerificationNotification,
 | 
			
		||||
          props: this.model.getPropsForVerificationNotification(),
 | 
			
		||||
          props: this.model.propsForVerificationNotification,
 | 
			
		||||
        };
 | 
			
		||||
      } else if (this.model.isEndSession()) {
 | 
			
		||||
      } else if (this.model.propsForResetSessionNotification) {
 | 
			
		||||
        return {
 | 
			
		||||
          Component: Components.ResetSessionNotification,
 | 
			
		||||
          props: this.model.getPropsForResetSessionNotification(),
 | 
			
		||||
          props: this.model.propsForResetSessionNotification,
 | 
			
		||||
        };
 | 
			
		||||
      } else if (this.model.isGroupUpdate()) {
 | 
			
		||||
      } else if (this.model.propsForGroupNotification) {
 | 
			
		||||
        return {
 | 
			
		||||
          Component: Components.GroupNotification,
 | 
			
		||||
          props: this.model.getPropsForGroupNotification(),
 | 
			
		||||
          props: this.model.propsForGroupNotification,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        Component: Components.Message,
 | 
			
		||||
        props: this.model.getPropsForMessage(),
 | 
			
		||||
        props: this.model.propsForMessage,
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    render() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@
 | 
			
		|||
    initialize(options) {
 | 
			
		||||
      const {
 | 
			
		||||
        Component,
 | 
			
		||||
        JSX,
 | 
			
		||||
        props,
 | 
			
		||||
        onClose,
 | 
			
		||||
        tagName,
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +29,7 @@
 | 
			
		|||
 | 
			
		||||
      this.tagName = tagName;
 | 
			
		||||
      this.className = className;
 | 
			
		||||
      this.JSX = JSX;
 | 
			
		||||
      this.Component = Component;
 | 
			
		||||
      this.onClose = onClose;
 | 
			
		||||
      this.onInitialRender = onInitialRender;
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +40,9 @@
 | 
			
		|||
    },
 | 
			
		||||
    update(props) {
 | 
			
		||||
      const updatedProps = this.augmentProps(props);
 | 
			
		||||
      const reactElement = React.createElement(this.Component, updatedProps);
 | 
			
		||||
      const reactElement = this.JSX
 | 
			
		||||
        ? this.JSX
 | 
			
		||||
        : React.createElement(this.Component, updatedProps);
 | 
			
		||||
      ReactDOM.render(reactElement, this.el, () => {
 | 
			
		||||
        if (this.hasRendered) {
 | 
			
		||||
          return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,129 +0,0 @@
 | 
			
		|||
/* global moment: false */
 | 
			
		||||
/* global Whisper: false */
 | 
			
		||||
/* global extension: false */
 | 
			
		||||
/* global i18n: false */
 | 
			
		||||
/* global _: false */
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line func-names
 | 
			
		||||
(function() {
 | 
			
		||||
  'use strict';
 | 
			
		||||
 | 
			
		||||
  window.Whisper = window.Whisper || {};
 | 
			
		||||
 | 
			
		||||
  function extendedRelativeTime(number, string) {
 | 
			
		||||
    return moment.duration(-1 * number, string).humanize(string !== 's');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const extendedFormats = {
 | 
			
		||||
    y: 'lll',
 | 
			
		||||
    M: `${i18n('timestampFormat_M') || 'MMM D'} LT`,
 | 
			
		||||
    d: 'ddd LT',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  function shortRelativeTime(number, string) {
 | 
			
		||||
    return moment.duration(number, string).humanize();
 | 
			
		||||
  }
 | 
			
		||||
  const shortFormats = {
 | 
			
		||||
    y: 'll',
 | 
			
		||||
    M: i18n('timestampFormat_M') || 'MMM D',
 | 
			
		||||
    d: 'ddd',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  function getRelativeTimeSpanString(rawTimestamp, options = {}) {
 | 
			
		||||
    _.defaults(options, { extended: false });
 | 
			
		||||
 | 
			
		||||
    const relativeTime = options.extended
 | 
			
		||||
      ? extendedRelativeTime
 | 
			
		||||
      : shortRelativeTime;
 | 
			
		||||
    const formats = options.extended ? extendedFormats : shortFormats;
 | 
			
		||||
 | 
			
		||||
    // Convert to moment timestamp if it isn't already
 | 
			
		||||
    const timestamp = moment(rawTimestamp);
 | 
			
		||||
    const now = moment();
 | 
			
		||||
    const timediff = moment.duration(now - timestamp);
 | 
			
		||||
 | 
			
		||||
    if (timediff.years() > 0) {
 | 
			
		||||
      return timestamp.format(formats.y);
 | 
			
		||||
    } else if (timediff.months() > 0 || timediff.days() > 6) {
 | 
			
		||||
      return timestamp.format(formats.M);
 | 
			
		||||
    } else if (timediff.days() > 0) {
 | 
			
		||||
      return timestamp.format(formats.d);
 | 
			
		||||
    } else if (timediff.hours() >= 1) {
 | 
			
		||||
      return relativeTime(timediff.hours(), 'h');
 | 
			
		||||
    } else if (timediff.minutes() >= 1) {
 | 
			
		||||
      // Note that humanize seems to jump to '1 hour' as soon as we cross 45 minutes
 | 
			
		||||
      return relativeTime(timediff.minutes(), 'm');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return relativeTime(timediff.seconds(), 's');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Whisper.TimestampView = Whisper.View.extend({
 | 
			
		||||
    initialize() {
 | 
			
		||||
      extension.windows.onClosed(this.clearTimeout.bind(this));
 | 
			
		||||
    },
 | 
			
		||||
    update() {
 | 
			
		||||
      this.clearTimeout();
 | 
			
		||||
      const millisNow = Date.now();
 | 
			
		||||
      let millis = this.$el.data('timestamp');
 | 
			
		||||
      if (millis === '') {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (millis >= millisNow) {
 | 
			
		||||
        millis = millisNow;
 | 
			
		||||
      }
 | 
			
		||||
      const result = this.getRelativeTimeSpanString(millis);
 | 
			
		||||
      this.delay = this.getDelay(millis);
 | 
			
		||||
      this.$el.text(result);
 | 
			
		||||
 | 
			
		||||
      const timestamp = moment(millis);
 | 
			
		||||
      this.$el.attr('title', timestamp.format('llll'));
 | 
			
		||||
 | 
			
		||||
      if (this.delay) {
 | 
			
		||||
        if (this.delay < 0) {
 | 
			
		||||
          this.delay = 1000;
 | 
			
		||||
        }
 | 
			
		||||
        this.timeout = setTimeout(this.update.bind(this), this.delay);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    clearTimeout() {
 | 
			
		||||
      clearTimeout(this.timeout);
 | 
			
		||||
    },
 | 
			
		||||
    getRelativeTimeSpanString(timestamp) {
 | 
			
		||||
      return getRelativeTimeSpanString(timestamp);
 | 
			
		||||
    },
 | 
			
		||||
    getDelay(rawTimestamp) {
 | 
			
		||||
      // Convert to moment timestamp if it isn't already
 | 
			
		||||
      const timestamp = moment(rawTimestamp);
 | 
			
		||||
      const now = moment();
 | 
			
		||||
      const timediff = moment.duration(now - timestamp);
 | 
			
		||||
 | 
			
		||||
      if (timediff.years() > 0) {
 | 
			
		||||
        return null;
 | 
			
		||||
      } else if (timediff.months() > 0 || timediff.days() > 6) {
 | 
			
		||||
        return null;
 | 
			
		||||
      } else if (timediff.days() > 0) {
 | 
			
		||||
        return moment(timestamp)
 | 
			
		||||
          .add(timediff.days() + 1, 'd')
 | 
			
		||||
          .diff(now);
 | 
			
		||||
      } else if (timediff.hours() >= 1) {
 | 
			
		||||
        return moment(timestamp)
 | 
			
		||||
          .add(timediff.hours() + 1, 'h')
 | 
			
		||||
          .diff(now);
 | 
			
		||||
      } else if (timediff.minutes() >= 1) {
 | 
			
		||||
        return moment(timestamp)
 | 
			
		||||
          .add(timediff.minutes() + 1, 'm')
 | 
			
		||||
          .diff(now);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return moment(timestamp)
 | 
			
		||||
        .add(1, 'm')
 | 
			
		||||
        .diff(now);
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
  Whisper.ExtendedTimestampView = Whisper.TimestampView.extend({
 | 
			
		||||
    getRelativeTimeSpanString(timestamp) {
 | 
			
		||||
      return getRelativeTimeSpanString(timestamp, { extended: true });
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
})();
 | 
			
		||||
| 
						 | 
				
			
			@ -489,10 +489,9 @@
 | 
			
		|||
        response.deviceId || 1,
 | 
			
		||||
        deviceName
 | 
			
		||||
      );
 | 
			
		||||
      await textsecure.storage.put(
 | 
			
		||||
        'regionCode',
 | 
			
		||||
        libphonenumber.util.getRegionCodeForNumber(number)
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const regionCode = libphonenumber.util.getRegionCodeForNumber(number);
 | 
			
		||||
      await textsecure.storage.put('regionCode', regionCode);
 | 
			
		||||
    },
 | 
			
		||||
    async clearSessionsAndPreKeys() {
 | 
			
		||||
      const store = textsecure.storage.protocol;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										28
									
								
								package.json
									
										
									
									
									
								
							
							
						
						
									
										28
									
								
								package.json
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -77,12 +77,18 @@
 | 
			
		|||
    "node-sass": "4.9.3",
 | 
			
		||||
    "os-locale": "2.1.0",
 | 
			
		||||
    "pify": "3.0.0",
 | 
			
		||||
    "protobufjs": "~6.8.6",
 | 
			
		||||
    "protobufjs": "6.8.6",
 | 
			
		||||
    "proxy-agent": "3.0.3",
 | 
			
		||||
    "react": "16.2.0",
 | 
			
		||||
    "react": "16.8.3",
 | 
			
		||||
    "react-contextmenu": "2.9.2",
 | 
			
		||||
    "react-dom": "16.2.0",
 | 
			
		||||
    "react-dom": "16.8.3",
 | 
			
		||||
    "react-redux": "6.0.1",
 | 
			
		||||
    "react-virtualized": "9.21.0",
 | 
			
		||||
    "read-last-lines": "1.3.0",
 | 
			
		||||
    "redux": "4.0.1",
 | 
			
		||||
    "redux-logger": "3.0.6",
 | 
			
		||||
    "redux-promise-middleware": "6.1.0",
 | 
			
		||||
    "reselect": "4.0.0",
 | 
			
		||||
    "rimraf": "2.6.2",
 | 
			
		||||
    "semver": "5.4.1",
 | 
			
		||||
    "spellchecker": "3.4.4",
 | 
			
		||||
| 
						 | 
				
			
			@ -99,13 +105,15 @@
 | 
			
		|||
    "@types/classnames": "2.2.3",
 | 
			
		||||
    "@types/filesize": "3.6.0",
 | 
			
		||||
    "@types/google-libphonenumber": "7.4.14",
 | 
			
		||||
    "@types/jquery": "3.3.1",
 | 
			
		||||
    "@types/jquery": "3.3.29",
 | 
			
		||||
    "@types/linkify-it": "2.0.3",
 | 
			
		||||
    "@types/lodash": "4.14.106",
 | 
			
		||||
    "@types/mocha": "5.0.0",
 | 
			
		||||
    "@types/qs": "6.5.1",
 | 
			
		||||
    "@types/react": "16.3.1",
 | 
			
		||||
    "@types/react-dom": "16.0.4",
 | 
			
		||||
    "@types/react": "16.8.5",
 | 
			
		||||
    "@types/react-dom": "16.8.2",
 | 
			
		||||
    "@types/react-redux": "7.0.1",
 | 
			
		||||
    "@types/redux-logger": "3.0.7",
 | 
			
		||||
    "@types/semver": "5.5.0",
 | 
			
		||||
    "@types/sinon": "4.3.1",
 | 
			
		||||
    "arraybuffer-loader": "1.0.3",
 | 
			
		||||
| 
						 | 
				
			
			@ -141,10 +149,10 @@
 | 
			
		|||
    "sinon": "4.4.2",
 | 
			
		||||
    "spectron": "5.0.0",
 | 
			
		||||
    "ts-loader": "4.1.0",
 | 
			
		||||
    "tslint": "5.9.1",
 | 
			
		||||
    "tslint-microsoft-contrib": "5.0.3",
 | 
			
		||||
    "tslint-react": "3.5.1",
 | 
			
		||||
    "typescript": "2.8.1",
 | 
			
		||||
    "tslint": "5.13.0",
 | 
			
		||||
    "tslint-microsoft-contrib": "6.0.0",
 | 
			
		||||
    "tslint-react": "3.6.0",
 | 
			
		||||
    "typescript": "3.3.3333",
 | 
			
		||||
    "webpack": "4.4.1"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,10 @@ const localeMessages = ipcRenderer.sendSync('locale-data');
 | 
			
		|||
window.theme = config.theme;
 | 
			
		||||
window.i18n = i18n.setup(locale, localeMessages);
 | 
			
		||||
 | 
			
		||||
window.getEnvironment = () => config.environment;
 | 
			
		||||
window.getVersion = () => config.version;
 | 
			
		||||
window.getAppInstance = () => config.appInstance;
 | 
			
		||||
 | 
			
		||||
// So far we're only using this for Signal.Types
 | 
			
		||||
const Signal = require('./js/modules/signal');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -20,10 +24,6 @@ window.Signal = Signal.setup({
 | 
			
		|||
  getRegionCode: () => null,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
window.getEnvironment = () => config.environment;
 | 
			
		||||
window.getVersion = () => config.version;
 | 
			
		||||
window.getAppInstance = () => config.appInstance;
 | 
			
		||||
 | 
			
		||||
window.closeSettings = () => ipcRenderer.send('close-settings');
 | 
			
		||||
 | 
			
		||||
window.getDeviceName = makeGetter('device-name');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,22 +9,22 @@ module.exports = {
 | 
			
		|||
    {
 | 
			
		||||
      name: 'Components',
 | 
			
		||||
      description: '',
 | 
			
		||||
      components: 'ts/components/*.tsx',
 | 
			
		||||
      components: 'ts/components/[^_]*.tsx',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'Conversation',
 | 
			
		||||
      description: 'Everything necessary to render a conversation',
 | 
			
		||||
      components: 'ts/components/conversation/*.tsx',
 | 
			
		||||
      components: 'ts/components/conversation/[^_]*.tsx',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'Media Gallery',
 | 
			
		||||
      description: 'Display media and documents in a conversation',
 | 
			
		||||
      components: 'ts/components/conversation/media-gallery/*.tsx',
 | 
			
		||||
      components: 'ts/components/conversation/media-gallery/[^_]*.tsx',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'Utility',
 | 
			
		||||
      description: 'Utility components used across the application',
 | 
			
		||||
      components: 'ts/components/utility/*.tsx',
 | 
			
		||||
      components: 'ts/components/utility/[^_]*.tsx',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'Test',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -91,7 +91,6 @@ button.emoji {
 | 
			
		|||
  margin-top: 3px;
 | 
			
		||||
 | 
			
		||||
  &:before {
 | 
			
		||||
    margin-top: 4px;
 | 
			
		||||
    content: '';
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    width: $button-height;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -143,63 +143,6 @@ a {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropoff {
 | 
			
		||||
  outline: solid 1px $blue;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$avatar-size: 48px;
 | 
			
		||||
 | 
			
		||||
.avatar {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  height: $avatar-size;
 | 
			
		||||
  width: $avatar-size;
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  background-size: cover;
 | 
			
		||||
  vertical-align: middle;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  line-height: $avatar-size;
 | 
			
		||||
  overflow-x: hidden;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  color: $color-white;
 | 
			
		||||
  font-size: 18px;
 | 
			
		||||
  background-color: $grey;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.group-info-input {
 | 
			
		||||
  background: $color-white;
 | 
			
		||||
 | 
			
		||||
  .group-avatar {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    padding: 2px 0px 0px 2px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .file-input .thumbnail,
 | 
			
		||||
  .thumbnail .avatar,
 | 
			
		||||
  img {
 | 
			
		||||
    height: 54px;
 | 
			
		||||
    width: 54px;
 | 
			
		||||
    border-radius: (54px / 2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .thumbnail:after {
 | 
			
		||||
    content: '';
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    height: 0;
 | 
			
		||||
    width: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    border-bottom: 10px solid $grey;
 | 
			
		||||
    border-left: 10px solid transparent;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input.name {
 | 
			
		||||
    padding: 0.5em;
 | 
			
		||||
    border: solid 1px #ccc;
 | 
			
		||||
    border-width: 0 0 1px 0;
 | 
			
		||||
    width: calc(100% - 84px);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.group-member-list,
 | 
			
		||||
.new-group-update {
 | 
			
		||||
  .summary {
 | 
			
		||||
| 
						 | 
				
			
			@ -257,123 +200,6 @@ $unread-badge-size: 21px;
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$new-contact-left-margin: 16px;
 | 
			
		||||
.new-contact .avatar {
 | 
			
		||||
  margin-left: $new-contact-left-margin;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Still used for the contact search view
 | 
			
		||||
.contact-details {
 | 
			
		||||
  $left-margin: 12px;
 | 
			
		||||
 | 
			
		||||
  vertical-align: middle;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  margin: 0 0 0 $left-margin;
 | 
			
		||||
  width: calc(
 | 
			
		||||
    100% - #{$avatar-size} - #{$new-contact-left-margin} - #{$left-margin} - #{(
 | 
			
		||||
        4/14
 | 
			
		||||
      ) + em}
 | 
			
		||||
  );
 | 
			
		||||
  text-align: left;
 | 
			
		||||
 | 
			
		||||
  p {
 | 
			
		||||
    overflow-x: hidden;
 | 
			
		||||
    overflow-y: hidden;
 | 
			
		||||
    height: 1.2em;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .name {
 | 
			
		||||
    display: block;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: 1em;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
    overflow-x: hidden;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .number {
 | 
			
		||||
    color: $color-light-60;
 | 
			
		||||
    font-size: $font-size-small;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.clickable {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .verified-icon {
 | 
			
		||||
    @include color-svg('../images/verified-check.svg', $grey);
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    width: 1.25em;
 | 
			
		||||
    height: 1.25em;
 | 
			
		||||
    vertical-align: text-bottom;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .body-wrapper {
 | 
			
		||||
    overflow-x: hidden;
 | 
			
		||||
    overflow-y: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.recipients-input {
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  .recipients-container {
 | 
			
		||||
    background-color: white;
 | 
			
		||||
    padding: 2px;
 | 
			
		||||
    border-bottom: 1px solid #f2f2f2;
 | 
			
		||||
    line-height: 24px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .recipient {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin: 0 2px 2px 0;
 | 
			
		||||
    padding: 0 5px;
 | 
			
		||||
    border-radius: 10px;
 | 
			
		||||
    background-color: $blue;
 | 
			
		||||
    color: white;
 | 
			
		||||
 | 
			
		||||
    &.error {
 | 
			
		||||
      background-color: #f00;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .remove {
 | 
			
		||||
      margin-left: 5px;
 | 
			
		||||
      padding: 0 2px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .results {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    margin: 0 0 0 20px;
 | 
			
		||||
    width: calc(100% - 30px);
 | 
			
		||||
    max-width: 300px;
 | 
			
		||||
    max-height: 55px * 3;
 | 
			
		||||
    overflow-y: auto;
 | 
			
		||||
    box-shadow: 0px 0px 1px rgba(#aaa, 0.8);
 | 
			
		||||
 | 
			
		||||
    .contact {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachment-preview {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  img {
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
.new-conversation .recipients-input .recipients::before {
 | 
			
		||||
  content: 'To: ';
 | 
			
		||||
}
 | 
			
		||||
.new-group-update .recipients-input .recipients::before {
 | 
			
		||||
  content: 'Add: ';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
$loading-height: 16px;
 | 
			
		||||
 | 
			
		||||
.loading {
 | 
			
		||||
| 
						 | 
				
			
			@ -437,10 +263,6 @@ $loading-height: 16px;
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.inbox {
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes loading {
 | 
			
		||||
  50% {
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
| 
						 | 
				
			
			@ -805,13 +627,6 @@ $loading-height: 16px;
 | 
			
		|||
  outline: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-security .inbox {
 | 
			
		||||
  .name,
 | 
			
		||||
  .body,
 | 
			
		||||
  .last-message,
 | 
			
		||||
  .sender,
 | 
			
		||||
  .conversation-title,
 | 
			
		||||
  .number {
 | 
			
		||||
    -webkit-text-security: square;
 | 
			
		||||
  }
 | 
			
		||||
.inbox {
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,6 +74,13 @@
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.left-pane-placeholder {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
.left-pane-wrapper {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.conversation-stack {
 | 
			
		||||
  .conversation {
 | 
			
		||||
    display: none;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2096,19 +2096,64 @@
 | 
			
		|||
 | 
			
		||||
.module-main-header {
 | 
			
		||||
  height: $header-height;
 | 
			
		||||
  margin-left: 16px;
 | 
			
		||||
  width: 300px;
 | 
			
		||||
 | 
			
		||||
  padding-left: 16px;
 | 
			
		||||
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
  border-bottom: 1px solid $color-gray-15;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-main-header__app-name {
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
  line-height: 24px;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  margin-left: 32px;
 | 
			
		||||
.module-main-header__search {
 | 
			
		||||
  margin-left: 12px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-main-header__search__input {
 | 
			
		||||
  height: 28px;
 | 
			
		||||
  width: 228px;
 | 
			
		||||
 | 
			
		||||
  border-radius: 14px;
 | 
			
		||||
  border: solid 1px $color-gray-15;
 | 
			
		||||
 | 
			
		||||
  padding-left: 30px;
 | 
			
		||||
  padding-right: 30px;
 | 
			
		||||
 | 
			
		||||
  color: $color-gray-90;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
 | 
			
		||||
  &::placeholder {
 | 
			
		||||
    color: $color-gray-45;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:focus {
 | 
			
		||||
    border: solid 1px blue;
 | 
			
		||||
    outline: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-main-header__search__icon {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  left: 8px;
 | 
			
		||||
  top: 6px;
 | 
			
		||||
  height: 16px;
 | 
			
		||||
  width: 16px;
 | 
			
		||||
 | 
			
		||||
  cursor: text;
 | 
			
		||||
  @include color-svg('../images/search.svg', $color-gray-60);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-main-header__search__cancel-icon {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  right: 8px;
 | 
			
		||||
  top: 7px;
 | 
			
		||||
  height: 14px;
 | 
			
		||||
  width: 14px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  @include color-svg('../images/x-16.svg', $color-gray-60);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module: Image
 | 
			
		||||
| 
						 | 
				
			
			@ -2771,6 +2816,216 @@
 | 
			
		|||
  background-color: $color-white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module: Highlighted Message Body
 | 
			
		||||
 | 
			
		||||
.module-message-body__highlight {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module: Search Results
 | 
			
		||||
 | 
			
		||||
.module-search-results {
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  max-height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-search-results__conversations-header {
 | 
			
		||||
  height: 36px;
 | 
			
		||||
  line-height: 36px;
 | 
			
		||||
 | 
			
		||||
  margin-left: 16px;
 | 
			
		||||
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  letter-spacing: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-search-results__no-results {
 | 
			
		||||
  margin-top: 27px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-search-results__contacts-header {
 | 
			
		||||
  height: 36px;
 | 
			
		||||
  line-height: 36px;
 | 
			
		||||
 | 
			
		||||
  margin-left: 16px;
 | 
			
		||||
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  letter-spacing: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-search-results__messages-header {
 | 
			
		||||
  height: 36px;
 | 
			
		||||
  line-height: 36px;
 | 
			
		||||
 | 
			
		||||
  margin-left: 16px;
 | 
			
		||||
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  letter-spacing: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module: Message Search Result
 | 
			
		||||
 | 
			
		||||
.module-message-search-result {
 | 
			
		||||
  padding: 8px;
 | 
			
		||||
  padding-left: 16px;
 | 
			
		||||
  padding-right: 16px;
 | 
			
		||||
  min-height: 64px;
 | 
			
		||||
  max-width: 300px;
 | 
			
		||||
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  align-items: flex-start;
 | 
			
		||||
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  &:hover {
 | 
			
		||||
    background-color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result--is-selected {
 | 
			
		||||
  background-color: $color-gray-05;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result__text {
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  margin-left: 12px;
 | 
			
		||||
  // parent - 48px (for avatar) - 16px (our right margin)
 | 
			
		||||
  max-width: calc(100% - 64px);
 | 
			
		||||
 | 
			
		||||
  display: inline-flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: stretch;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result__header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result__header__from {
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  flex-shrink: 1;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  line-height: 18px;
 | 
			
		||||
 | 
			
		||||
  overflow-x: hidden;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
  color: $color-gray-90;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result__header__timestamp {
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
  margin-left: 6px;
 | 
			
		||||
 | 
			
		||||
  font-size: 11px;
 | 
			
		||||
  line-height: 16px;
 | 
			
		||||
  letter-spacing: 0.3px;
 | 
			
		||||
 | 
			
		||||
  overflow-x: hidden;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
  text-transform: uppercase;
 | 
			
		||||
 | 
			
		||||
  color: $color-gray-60;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result__header__name {
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
}
 | 
			
		||||
.module-mesages-search-result__header__group {
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-message-search-result__body {
 | 
			
		||||
  margin-top: 1px;
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  flex-shrink: 1;
 | 
			
		||||
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
 | 
			
		||||
  color: $color-gray-60;
 | 
			
		||||
 | 
			
		||||
  max-height: 3.6em;
 | 
			
		||||
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  display: -webkit-box;
 | 
			
		||||
  -webkit-line-clamp: 3;
 | 
			
		||||
  -webkit-box-orient: vertical;
 | 
			
		||||
 | 
			
		||||
  // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use
 | 
			
		||||
  //   ... as the truncation indicator. That's not a solution that works well for
 | 
			
		||||
  //   all languages. More resources:
 | 
			
		||||
  //     - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/
 | 
			
		||||
  //     - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module: Left Pane
 | 
			
		||||
 | 
			
		||||
.module-left-pane {
 | 
			
		||||
  background-color: $color-gray-02;
 | 
			
		||||
  border-right: 1px solid $color-gray-15;
 | 
			
		||||
 | 
			
		||||
  display: inline-flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
 | 
			
		||||
  width: 300px;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-left-pane__header {
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
  flex-grow: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-left-pane__list {
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  flex-shrink: 1;
 | 
			
		||||
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Module: Start New Conversation
 | 
			
		||||
 | 
			
		||||
.module-start-new-conversation {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
  padding-top: 8px;
 | 
			
		||||
  padding-bottom: 8px;
 | 
			
		||||
  padding-left: 16px;
 | 
			
		||||
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
 | 
			
		||||
  &:hover {
 | 
			
		||||
    background-color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-start-new-conversation__content {
 | 
			
		||||
  margin-left: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-start-new-conversation__number {
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.module-start-new-conversation__text {
 | 
			
		||||
  margin-top: 3px;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
  color: $color-gray-60;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Third-party module: react-contextmenu
 | 
			
		||||
 | 
			
		||||
.react-contextmenu {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,6 @@
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    &:before {
 | 
			
		||||
      margin-top: 4px;
 | 
			
		||||
      content: '';
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      height: 24px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -166,6 +166,7 @@ body.dark-theme {
 | 
			
		|||
 | 
			
		||||
  button.emoji {
 | 
			
		||||
    &:before {
 | 
			
		||||
      margin-top: 4px;
 | 
			
		||||
      @include color-svg('../images/smile.svg', $color-dark-30);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -1320,8 +1321,32 @@ body.dark-theme {
 | 
			
		|||
 | 
			
		||||
  // Module: Main Header
 | 
			
		||||
 | 
			
		||||
  .module-main-header__app-name {
 | 
			
		||||
    color: $color-dark-05;
 | 
			
		||||
  .module-main-header {
 | 
			
		||||
    border-bottom: 1px solid $color-gray-75;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-main-header__search__input {
 | 
			
		||||
    background-color: $color-gray-95;
 | 
			
		||||
    border-radius: 14px;
 | 
			
		||||
    border: solid 1px $color-gray-75;
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
 | 
			
		||||
    &::placeholder {
 | 
			
		||||
      color: $color-gray-45;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus {
 | 
			
		||||
      border: solid 1px blue;
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-main-header__search__icon {
 | 
			
		||||
    @include color-svg('../images/search.svg', $color-gray-25);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-main-header__search__cancel-icon {
 | 
			
		||||
    @include color-svg('../images/x.svg', $color-gray-25);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Module: Image
 | 
			
		||||
| 
						 | 
				
			
			@ -1450,6 +1475,100 @@ body.dark-theme {
 | 
			
		|||
    background-color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Module: Caption Editor
 | 
			
		||||
 | 
			
		||||
  .module-caption-editor {
 | 
			
		||||
    background-color: $color-black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-caption-editor__close-button {
 | 
			
		||||
    @include color-svg('../images/x.svg', $color-white);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-caption-editor__media-container {
 | 
			
		||||
    background-color: $color-black;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-caption-editor__caption-input {
 | 
			
		||||
    border: 1px solid $color-white;
 | 
			
		||||
    color: $color-white;
 | 
			
		||||
    background-color: $color-black;
 | 
			
		||||
 | 
			
		||||
    &::placeholder {
 | 
			
		||||
      color: $color-white-07;
 | 
			
		||||
    }
 | 
			
		||||
    &:focus {
 | 
			
		||||
      border: 1px solid $color-signal-blue;
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-caption-editor__save-button {
 | 
			
		||||
    background-color: $color-signal-blue;
 | 
			
		||||
    color: $color-white;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Module: Highlighted Message Body
 | 
			
		||||
 | 
			
		||||
  // Module: Search Results
 | 
			
		||||
 | 
			
		||||
  .module-search-results__conversations-header {
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
  .module-search-results__contacts-header {
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
  .module-search-results__messages-header {
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Module: Message Search Result
 | 
			
		||||
 | 
			
		||||
  .module-message-search-result {
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: $color-dark-70;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-message-search-result--is-selected {
 | 
			
		||||
    background-color: $color-dark-70;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-message-search-result__header__from {
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-message-search-result__header__timestamp {
 | 
			
		||||
    color: $color-gray-25;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-message-search-result__body {
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Module: Left Pane
 | 
			
		||||
 | 
			
		||||
  .module-left-pane {
 | 
			
		||||
    background-color: $color-dark-85;
 | 
			
		||||
    border-right: 1px solid $color-gray-75;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Module: Start New Conversation
 | 
			
		||||
 | 
			
		||||
  .module-start-new-conversation {
 | 
			
		||||
    &:hover {
 | 
			
		||||
      background-color: $color-dark-70;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-start-new-conversation__number {
 | 
			
		||||
    color: $color-gray-05;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .module-start-new-conversation__text {
 | 
			
		||||
    color: $color-gray-45;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Third-party module: react-contextmenu
 | 
			
		||||
 | 
			
		||||
  .react-contextmenu {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
/* global chai, Whisper */
 | 
			
		||||
/* global chai, Whisper, _, Backbone */
 | 
			
		||||
 | 
			
		||||
mocha.setup('bdd');
 | 
			
		||||
window.assert = chai.assert;
 | 
			
		||||
| 
						 | 
				
			
			@ -74,8 +74,13 @@ function deleteIndexedDB() {
 | 
			
		|||
before(async () => {
 | 
			
		||||
  await deleteIndexedDB();
 | 
			
		||||
  await window.Signal.Data.removeAll();
 | 
			
		||||
  await window.storage.fetch();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
window.clearDatabase = async () => {
 | 
			
		||||
  await window.Signal.Data.removeAll();
 | 
			
		||||
  await window.storage.fetch();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
window.Whisper = window.Whisper || {};
 | 
			
		||||
window.Whisper.events = _.clone(Backbone.Events);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,48 +0,0 @@
 | 
			
		|||
/* global Whisper */
 | 
			
		||||
 | 
			
		||||
'use strict';
 | 
			
		||||
 | 
			
		||||
describe('ConversationController', () => {
 | 
			
		||||
  it('sorts conversations based on timestamp then by intl-friendly title', () => {
 | 
			
		||||
    const collection = window.getInboxCollection();
 | 
			
		||||
    collection.reset([]);
 | 
			
		||||
 | 
			
		||||
    collection.add(
 | 
			
		||||
      new Whisper.Conversation({
 | 
			
		||||
        name: 'No timestamp',
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
    collection.add(
 | 
			
		||||
      new Whisper.Conversation({
 | 
			
		||||
        name: 'B',
 | 
			
		||||
        timestamp: 20,
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
    collection.add(
 | 
			
		||||
      new Whisper.Conversation({
 | 
			
		||||
        name: 'C',
 | 
			
		||||
        timestamp: 20,
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
    collection.add(
 | 
			
		||||
      new Whisper.Conversation({
 | 
			
		||||
        name: 'Á',
 | 
			
		||||
        timestamp: 20,
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
    collection.add(
 | 
			
		||||
      new Whisper.Conversation({
 | 
			
		||||
        name: 'First!',
 | 
			
		||||
        timestamp: 30,
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    assert.strictEqual(collection.at('0').get('name'), 'First!');
 | 
			
		||||
    assert.strictEqual(collection.at('1').get('name'), 'Á');
 | 
			
		||||
    assert.strictEqual(collection.at('2').get('name'), 'B');
 | 
			
		||||
    assert.strictEqual(collection.at('3').get('name'), 'C');
 | 
			
		||||
    assert.strictEqual(collection.at('4').get('name'), 'No timestamp');
 | 
			
		||||
 | 
			
		||||
    collection.reset([]);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										423
									
								
								test/index.html
									
										
									
									
									
								
							
							
						
						
									
										423
									
								
								test/index.html
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -16,19 +16,22 @@
 | 
			
		|||
  <div id="render-dark-theme" class='index' style="width: 800; height: 500; margin:10px; border: solid 1px black;">
 | 
			
		||||
  </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='app-loading-screen'>
 | 
			
		||||
    <div class='content'>
 | 
			
		||||
      <img src='/images/icon_128.png'>
 | 
			
		||||
      <img src='images/icon_250.png' height='150'>
 | 
			
		||||
      <div class='container'>
 | 
			
		||||
        <span class='dot'></span>
 | 
			
		||||
        <span class='dot'></span>
 | 
			
		||||
        <span class='dot'></span>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class='message'>{{ message }}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='conversation-loading-screen'>
 | 
			
		||||
    <div class='content'>
 | 
			
		||||
      <img src='/images/icon_128.png'>
 | 
			
		||||
      <img src='images/icon_128.png'>
 | 
			
		||||
      <div class='container'>
 | 
			
		||||
        <span class='dot'></span>
 | 
			
		||||
        <span class='dot'></span>
 | 
			
		||||
| 
						 | 
				
			
			@ -36,52 +39,47 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='two-column'>
 | 
			
		||||
    <div class='gutter'>
 | 
			
		||||
      <div class='network-status-container'></div>
 | 
			
		||||
        <div class='title-bar active' id='header'>
 | 
			
		||||
          <h1>Signal</h1>
 | 
			
		||||
          <div class='tool-bar clearfix'>
 | 
			
		||||
            <input type='search' class='search' placeholder='{{ searchForPeopleOrGroups }}' dir='auto'>
 | 
			
		||||
            <span class='search-icon'></span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='content'>
 | 
			
		||||
          <div class='conversations scrollable inbox'></div>
 | 
			
		||||
          <div class='conversations scrollable search-results hide'>
 | 
			
		||||
            <div class='new-contact contact hide'></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      <div class='left-pane-placeholder'></div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class='conversation-stack'>
 | 
			
		||||
      <div class='conversation placeholder'>
 | 
			
		||||
        <div class='conversation-header'></div>
 | 
			
		||||
        <div class='container'>
 | 
			
		||||
          <div class='content'>
 | 
			
		||||
          <img src='/images/icon_128.png' />
 | 
			
		||||
            <img src='images/icon_128.png' />
 | 
			
		||||
            <h3>{{ welcomeToSignal }}</h3>
 | 
			
		||||
            <p>{{ selectAContact }}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class='lightbox-container'></div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
 | 
			
		||||
    <button class='text {{ cssClass }}' alt='{{ moreBelow }}'>
 | 
			
		||||
      <div class='icon'></div>
 | 
			
		||||
    <button class='text module-scroll-down__button {{ buttonClass }}' alt='{{ moreBelow }}'>
 | 
			
		||||
      <div class='module-scroll-down__icon'></div>
 | 
			
		||||
    </button>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='last-seen-indicator-view'>
 | 
			
		||||
    <div class='bar'>
 | 
			
		||||
      <div class='text'>
 | 
			
		||||
    <div class='module-last-seen-indicator__bar'/>
 | 
			
		||||
    <div class='module-last-seen-indicator__text'>
 | 
			
		||||
      {{ unreadMessages }}
 | 
			
		||||
    </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='expired_alert'>
 | 
			
		||||
    <a target='_blank' href='https://signal.org/download/'>
 | 
			
		||||
      <button class='upgrade'>{{ upgrade }}</button>
 | 
			
		||||
    </a>
 | 
			
		||||
    {{ expiredWarning }}
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='banner'>
 | 
			
		||||
    <div class='body'>
 | 
			
		||||
      <span class='icon warning'></span>
 | 
			
		||||
| 
						 | 
				
			
			@ -89,12 +87,11 @@
 | 
			
		|||
      <span class='icon dismiss'></span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='toast'>
 | 
			
		||||
    {{ toastMessage }}
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='hint'>
 | 
			
		||||
    <p> {{ content }}</p>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='conversation'>
 | 
			
		||||
    <div class='conversation-header'></div>
 | 
			
		||||
    <div class='main panel'>
 | 
			
		||||
| 
						 | 
				
			
			@ -105,28 +102,41 @@
 | 
			
		|||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class='bottom-bar' id='footer'>
 | 
			
		||||
          <form class='send clearfix'>
 | 
			
		||||
              <div class='attachment-previews'></div>
 | 
			
		||||
          <div class='emoji-panel-container'></div>
 | 
			
		||||
          <div class='attachment-list'></div>
 | 
			
		||||
          <div class='compose'>
 | 
			
		||||
            <form class='send clearfix file-input'>
 | 
			
		||||
              <div class='flex'>
 | 
			
		||||
                <div class='choose-file'>
 | 
			
		||||
                    <button class='paperclip thumbnail'></button>
 | 
			
		||||
                    <input type='file' class='file-input'>
 | 
			
		||||
                </div>
 | 
			
		||||
                <button class='emoji'></button>
 | 
			
		||||
                <textarea class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto'></textarea>
 | 
			
		||||
                <div class='capture-audio'>
 | 
			
		||||
                    <!--<button class='microphone'></button>-->
 | 
			
		||||
                    <button class='microphone'></button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class='android-length-warning'>
 | 
			
		||||
                    {{ android-length-warning }}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class='choose-file'>
 | 
			
		||||
                    <button class='paperclip thumbnail'></button>
 | 
			
		||||
                    <input type='file' class='file-input' multiple='multiple'>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
              </div>
 | 
			
		||||
            </form>
 | 
			
		||||
          </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='message-list'>
 | 
			
		||||
    <div class='messages'></div>
 | 
			
		||||
    <div class='typing-container'></div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='recorder'>
 | 
			
		||||
      <button class='finish'><span class='icon'></span></button>
 | 
			
		||||
      <span class='time'>0:00</span>
 | 
			
		||||
      <button class='close'><span class='icon'></span></button>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='confirmation-dialog'>
 | 
			
		||||
    <div class="content">
 | 
			
		||||
      <div class='message'>{{ message }}</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -138,21 +148,7 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='attachment-preview'>
 | 
			
		||||
        <img src='{{ source }}' class='preview' />
 | 
			
		||||
        <a class='x close' alt='remove attachment' href='#'></a>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='new-group-update'>
 | 
			
		||||
      <div class='conversation-header'>
 | 
			
		||||
        <button class='back'></button>
 | 
			
		||||
        <button class='send check'></button>
 | 
			
		||||
        <span class='conversation-title'>Update group</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      {{> group_info_input }}
 | 
			
		||||
      <div class='container'>
 | 
			
		||||
        <div class='scrollable'></div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='identicon-svg'>
 | 
			
		||||
      <svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>
 | 
			
		||||
         <circle cx='50' cy='50' r='40' fill='{{ color }}' />
 | 
			
		||||
| 
						 | 
				
			
			@ -161,49 +157,7 @@
 | 
			
		|||
          </text>
 | 
			
		||||
      </svg>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='avatar'>
 | 
			
		||||
    <span aria-hidden class='avatar
 | 
			
		||||
      {{ ^avatar.url }}
 | 
			
		||||
        {{ avatar.color }}
 | 
			
		||||
      {{ /avatar.url }}
 | 
			
		||||
      '
 | 
			
		||||
      {{ #avatar.url }}
 | 
			
		||||
        style='background-image: url("{{ avatar.url }}");'
 | 
			
		||||
      {{ /avatar.url }}
 | 
			
		||||
    >
 | 
			
		||||
        {{ avatar.content }}
 | 
			
		||||
    </span>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='contact_pill'>
 | 
			
		||||
    <span>{{ name }}</span><span class='remove'>x</span>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='contact_name_and_number'>
 | 
			
		||||
      <h3 class='name' dir='auto'> {{ title }} </h3>
 | 
			
		||||
      <div class='number'>{{ #isVerified }}<span class='verified-icon'></span> {{ verified }} ·{{ /isVerified }} {{ number }}</div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='contact'>
 | 
			
		||||
      {{> avatar }}
 | 
			
		||||
      <div class='contact-details'> {{> contact_name_and_number }} </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='new-contact'>
 | 
			
		||||
      {{> avatar }}
 | 
			
		||||
      <div class='contact-details'>
 | 
			
		||||
          {{> contact_name_and_number }}
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='conversation-preview'>
 | 
			
		||||
      {{> avatar }}
 | 
			
		||||
      <div class='contact-details'>
 | 
			
		||||
          <span class='last-timestamp' data-timestamp='{{ last_message_timestamp }}' dir='auto' > </span>
 | 
			
		||||
          {{> contact_name_and_number }}
 | 
			
		||||
          {{ #unreadCount }}
 | 
			
		||||
            <span class='unread-count'>{{ unreadCount }}</span>
 | 
			
		||||
          {{ /unreadCount }}
 | 
			
		||||
          {{ #last_message }}
 | 
			
		||||
            <p class='last-message' dir='auto'></p>
 | 
			
		||||
          {{ /last_message }}
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='phone-number'>
 | 
			
		||||
      <div class='phone-input-form'>
 | 
			
		||||
          <div class='number-container'>
 | 
			
		||||
| 
						 | 
				
			
			@ -211,18 +165,22 @@
 | 
			
		|||
          </div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='file-size-modal'>
 | 
			
		||||
      {{ file-size-warning }}
 | 
			
		||||
      ({{ limit }}{{ units }})
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='attachment-type-modal'>
 | 
			
		||||
      Sorry, your attachment has a type, {{type}}, that is not currently supported.
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='group-member-list'>
 | 
			
		||||
    <div class='container'>
 | 
			
		||||
      {{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='key-verification'>
 | 
			
		||||
    <div class='container'>
 | 
			
		||||
      {{ ^hasTheirKey }}
 | 
			
		||||
| 
						 | 
				
			
			@ -253,55 +211,42 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <!-- index -->
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='group_info_input'>
 | 
			
		||||
      <div class='group-info-input'>
 | 
			
		||||
        <div class='group-avatar'>
 | 
			
		||||
          <div class='choose-file attachment-previews thumbnail'>
 | 
			
		||||
            {{> avatar }}
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='clear-data'>
 | 
			
		||||
    {{#isStep1}}
 | 
			
		||||
    <div class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon alert-outline-red'></span>
 | 
			
		||||
          <div class='header'>{{ header }}</div>
 | 
			
		||||
          <div class='body-text-wide'>{{ body }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
          <input type='file' name='avatar' class='file-input'>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          <div>
 | 
			
		||||
            <a class='button neutral cancel'>{{ cancelButton }}</a>
 | 
			
		||||
            <a class='button destructive delete-all-data'>{{ deleteButton }}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        <input type='text' name='name' class='name' placeholder='Group Name' value='{{ name }}'>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep1}}
 | 
			
		||||
    {{#isStep2}}
 | 
			
		||||
    <div id='step3' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon delete'></span>
 | 
			
		||||
          <div class='header'>{{ deleting }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='progress'>
 | 
			
		||||
          <div class='bar-container'>
 | 
			
		||||
            <div class='bar progress-bar progress-bar-striped active'></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep2}}
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='new-conversation'>
 | 
			
		||||
      <div class='conversation-header'>
 | 
			
		||||
        <button class='back'></button>
 | 
			
		||||
        <button class='create check hide'></button>
 | 
			
		||||
        <span class='conversation-title'>New Message</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      {{> group_info_input }}
 | 
			
		||||
      <div class='container'>
 | 
			
		||||
        <div class='scrollable'>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='recipients-input'>
 | 
			
		||||
      <div class='recipients-container'>
 | 
			
		||||
        <span class='recipients'></span>
 | 
			
		||||
        <input type='text' class='search' placeholder='{{ placeholder }}' dir='auto' />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class='results'>
 | 
			
		||||
        <div class='new-contact contact hide'></div>
 | 
			
		||||
        <div class='contacts'></div>
 | 
			
		||||
      </div>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='generic-error'>
 | 
			
		||||
    <p>{{ message }}</p>
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='error-icon'>
 | 
			
		||||
    <span class='error-icon'>
 | 
			
		||||
    </span>
 | 
			
		||||
    {{ #message }}
 | 
			
		||||
      <span class='error-message'>{{message}}</span>
 | 
			
		||||
    {{ /message }}
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='link_to_support'>
 | 
			
		||||
    <a href='http://support.signal.org/hc/articles/213134107' target='_blank'>
 | 
			
		||||
      {{ learnMore }}
 | 
			
		||||
    </a>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='networkStatus'>
 | 
			
		||||
    <div class='network-status-message'>
 | 
			
		||||
      <h3>{{ message }}</h3>
 | 
			
		||||
| 
						 | 
				
			
			@ -318,11 +263,202 @@
 | 
			
		|||
      </div>
 | 
			
		||||
    {{/action }}
 | 
			
		||||
  </script>
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='file-view'>
 | 
			
		||||
    <div class='icon'></div>
 | 
			
		||||
    <div class='text'>
 | 
			
		||||
      <div class='fileName' alt='{{ fileName }}' title='{{ altText }}'>{{ fileName }}</div>
 | 
			
		||||
      <div class='fileSize'>{{ fileSize }}</div>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='import-flow-template'>
 | 
			
		||||
    {{#isStep2}}
 | 
			
		||||
    <div id='step2' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon folder-outline'></span>
 | 
			
		||||
          <div class='header'>{{ chooseHeader }}</div>
 | 
			
		||||
          <div class='body-text'>{{ choose }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          <div>
 | 
			
		||||
            <a class='button choose'>{{ chooseButton }}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep2}}
 | 
			
		||||
    {{#isStep3}}
 | 
			
		||||
    <div id='step3' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon import'></span>
 | 
			
		||||
          <div class='header'>{{ importingHeader }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='progress'>
 | 
			
		||||
          <div class='bar-container'>
 | 
			
		||||
            <div class='bar progress-bar progress-bar-striped active'></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep3}}
 | 
			
		||||
    {{#isStep4}}
 | 
			
		||||
    <div id='step4' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon check-circle-outline'></span>
 | 
			
		||||
          <div class='header'>{{ completeHeader }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          {{#restartButton}}
 | 
			
		||||
          <div>
 | 
			
		||||
            <a class='button restart'>{{ restartButton }}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
          {{/restartButton}}
 | 
			
		||||
          {{#registerButton}}
 | 
			
		||||
          <div>
 | 
			
		||||
            <a class='button register'>{{ registerButton }}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
          {{/registerButton}}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep4}}
 | 
			
		||||
 | 
			
		||||
    {{#isError}}
 | 
			
		||||
    <div id='error' class='step'>
 | 
			
		||||
      <div class='inner error-dialog clearfix'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon alert-outline'></span>
 | 
			
		||||
          <div class='header'>{{ errorHeader }}</div>
 | 
			
		||||
          <div class='body-text-wide'>
 | 
			
		||||
            {{ errorMessageFirst }}
 | 
			
		||||
            <p>{{ errorMessageSecond }}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          <div>
 | 
			
		||||
            <a class='button choose'>{{ chooseButton }}</a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isError}}
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='link-flow-template'>
 | 
			
		||||
    {{#isStep3}}
 | 
			
		||||
    <div id='step3' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <div class='header'>{{ linkYourPhone }}</div>
 | 
			
		||||
          <div id="qr">
 | 
			
		||||
            <div class='container'>
 | 
			
		||||
              <span class='dot'></span>
 | 
			
		||||
              <span class='dot'></span>
 | 
			
		||||
              <span class='dot'></span>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          <div class='instructions'>
 | 
			
		||||
            <div class='android'>
 | 
			
		||||
              <div class='label'>
 | 
			
		||||
                <span class='os-icon android'></span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class='body'>
 | 
			
		||||
                <div>→ {{ signalSettings }}</div>
 | 
			
		||||
                <div>→ {{ linkedDevices }}</div>
 | 
			
		||||
                <div>→ {{ androidFinalStep }}</div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class='apple'>
 | 
			
		||||
              <div class='label'>
 | 
			
		||||
                <span class='os-icon apple'></span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class='body'>
 | 
			
		||||
                <div>→ {{ signalSettings }}</div>
 | 
			
		||||
                <div>→ {{ linkedDevices }}</div>
 | 
			
		||||
                <div>→ {{ appleFinalStep }}</div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep3}}
 | 
			
		||||
    {{#isStep4}}
 | 
			
		||||
    <form id='link-phone'>
 | 
			
		||||
      <div id='step4' class='step'>
 | 
			
		||||
        <div class='inner'>
 | 
			
		||||
          <div class='step-body'>
 | 
			
		||||
            <span class='banner-icon lead-pencil'></span>
 | 
			
		||||
            <div class='header'>{{ chooseName }}</div>
 | 
			
		||||
            <div>
 | 
			
		||||
              <input type='text' class='device-name' spellcheck='false' maxlength='50' />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
            <div class='nav'>
 | 
			
		||||
              <div>
 | 
			
		||||
                <a class='button finish'>{{ finishLinkingPhoneButton }}</a>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </form>
 | 
			
		||||
    {{/isStep4}}
 | 
			
		||||
    {{#isStep5}}
 | 
			
		||||
    <div id='step5' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon sync'></span>
 | 
			
		||||
          <div class='header'>{{ syncing }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='progress'>
 | 
			
		||||
          <div class='bar-container'>
 | 
			
		||||
            <div class='bar progress-bar progress-bar-striped active'></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isStep5}}
 | 
			
		||||
 | 
			
		||||
    {{#isError}}
 | 
			
		||||
    <div id='error' class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <span class='banner-icon alert-outline'></span>
 | 
			
		||||
          <div class='header'>{{ errorHeader }}</div>
 | 
			
		||||
          <div class='body'>{{ errorMessage }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          <a class='button try-again'>{{ errorButton }}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{/isError}}
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
  <script type='text/x-tmpl-mustache' id='standalone'>
 | 
			
		||||
    <div class='step'>
 | 
			
		||||
      <div class='inner'>
 | 
			
		||||
        <div class='step-body'>
 | 
			
		||||
          <img class='banner-image' src='images/icon_128.png' />
 | 
			
		||||
          <div class='header'>Create your Signal Account</div>
 | 
			
		||||
          <div id='phone-number-input'>
 | 
			
		||||
            <div class='phone-input-form'>
 | 
			
		||||
                <div id='number-container' class='number-container'>
 | 
			
		||||
                  <input type='tel' class='number' placeholder='Phone Number' />
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class='clearfix'>
 | 
			
		||||
            <a class='button' id='request-sms'>Send SMS</a>
 | 
			
		||||
            <a class='link' id='request-voice' tabindex=-1>Call</a>
 | 
			
		||||
          </div>
 | 
			
		||||
          <input class='form-control' type='text' pattern='\s*[0-9]{3}-?[0-9]{3}\s*' title='Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one' id='code' placeholder='Verification Code' autocomplete='off'>
 | 
			
		||||
          <div id='error' class='collapse'></div>
 | 
			
		||||
          <div id=status></div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class='nav'>
 | 
			
		||||
          <a class='button' id='verifyCode' data-loading-text='Please wait...'>Register</a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </script>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -356,13 +492,9 @@
 | 
			
		|||
  <script type='text/javascript' src='../js/views/whisper_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/debug_log_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/attachment_preview_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/file_input_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/list_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/conversation_list_item_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/conversation_list_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/contact_list_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/attachment_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/timestamp_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/message_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
 | 
			
		||||
| 
						 | 
				
			
			@ -370,7 +502,6 @@
 | 
			
		|||
  <script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/conversation_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/conversation_search_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/hint_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/inbox_view.js' data-cover></script>
 | 
			
		||||
  <script type='text/javascript' src='../js/views/network_status_view.js'></script>
 | 
			
		||||
| 
						 | 
				
			
			@ -384,11 +515,9 @@
 | 
			
		|||
  <script type="text/javascript" src="metadata/SecretSessionCipher_test.js"></script>
 | 
			
		||||
 | 
			
		||||
  <script type="text/javascript" src="views/whisper_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/attachment_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/timestamp_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/list_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/inbox_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/conversation_search_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/network_status_view_test.js"></script>
 | 
			
		||||
  <script type="text/javascript" src="views/last_seen_indicator_view_test.js"></script>
 | 
			
		||||
  <script type='text/javascript' src='views/scroll_down_button_view_test.js'></script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -163,51 +163,3 @@ describe('Conversation', () => {
 | 
			
		|||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('Conversation search', () => {
 | 
			
		||||
  let convo;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    convo = new Whisper.ConversationCollection().add({
 | 
			
		||||
      id: '+14155555555',
 | 
			
		||||
      type: 'private',
 | 
			
		||||
      name: 'John Doe',
 | 
			
		||||
    });
 | 
			
		||||
    await window.Signal.Data.saveConversation(convo.attributes, {
 | 
			
		||||
      Conversation: Whisper.Conversation,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(clearDatabase);
 | 
			
		||||
 | 
			
		||||
  async function testSearch(queries) {
 | 
			
		||||
    await Promise.all(
 | 
			
		||||
      queries.map(async query => {
 | 
			
		||||
        const collection = new Whisper.ConversationCollection();
 | 
			
		||||
        await collection.search(query);
 | 
			
		||||
 | 
			
		||||
        assert.isDefined(collection.get(convo.id), `no result for "${query}"`);
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  it('matches by partial phone number', () => {
 | 
			
		||||
    return testSearch([
 | 
			
		||||
      '1',
 | 
			
		||||
      '4',
 | 
			
		||||
      '+1',
 | 
			
		||||
      '415',
 | 
			
		||||
      '4155',
 | 
			
		||||
      '4155555555',
 | 
			
		||||
      '14155555555',
 | 
			
		||||
      '+14155555555',
 | 
			
		||||
    ]);
 | 
			
		||||
  });
 | 
			
		||||
  it('matches by name', () => {
 | 
			
		||||
    return testSearch(['John', 'Doe', 'john', 'doe', 'John Doe', 'john doe']);
 | 
			
		||||
  });
 | 
			
		||||
  it('does not match +', async () => {
 | 
			
		||||
    const collection = new Whisper.ConversationCollection();
 | 
			
		||||
    await collection.search('+');
 | 
			
		||||
    assert.isUndefined(collection.get(convo.id), 'got result for "+"');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,13 +12,11 @@ describe('Attachment', () => {
 | 
			
		|||
    it('should sanitize left-to-right order override character', async () => {
 | 
			
		||||
      const input = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'test\u202Dfig.exe',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
      const expected = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'test\uFFFDfig.exe',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
| 
						 | 
				
			
			@ -30,13 +28,11 @@ describe('Attachment', () => {
 | 
			
		|||
    it('should sanitize right-to-left order override character', async () => {
 | 
			
		||||
      const input = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'test\u202Efig.exe',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
      const expected = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'test\uFFFDfig.exe',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
| 
						 | 
				
			
			@ -48,13 +44,11 @@ describe('Attachment', () => {
 | 
			
		|||
    it('should sanitize multiple override characters', async () => {
 | 
			
		||||
      const input = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'test\u202e\u202dlol\u202efig.exe',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
      const expected = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
| 
						 | 
				
			
			@ -72,7 +66,6 @@ describe('Attachment', () => {
 | 
			
		|||
      fileName => {
 | 
			
		||||
        const input = {
 | 
			
		||||
          contentType: 'image/jpeg',
 | 
			
		||||
          data: null,
 | 
			
		||||
          fileName,
 | 
			
		||||
          size: 1111,
 | 
			
		||||
        };
 | 
			
		||||
| 
						 | 
				
			
			@ -131,7 +124,6 @@ describe('Attachment', () => {
 | 
			
		|||
    it('should remove existing schema version', () => {
 | 
			
		||||
      const input = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'foo.jpg',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
        schemaVersion: 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +131,6 @@ describe('Attachment', () => {
 | 
			
		|||
 | 
			
		||||
      const expected = {
 | 
			
		||||
        contentType: 'image/jpeg',
 | 
			
		||||
        data: null,
 | 
			
		||||
        fileName: 'foo.jpg',
 | 
			
		||||
        size: 1111,
 | 
			
		||||
      };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,59 +0,0 @@
 | 
			
		|||
/* global assert, storage, Whisper */
 | 
			
		||||
 | 
			
		||||
'use strict';
 | 
			
		||||
 | 
			
		||||
describe('AttachmentView', () => {
 | 
			
		||||
  let convo;
 | 
			
		||||
 | 
			
		||||
  before(async () => {
 | 
			
		||||
    await clearDatabase();
 | 
			
		||||
 | 
			
		||||
    convo = new Whisper.Conversation({ id: 'foo' });
 | 
			
		||||
    convo.messageCollection.add({
 | 
			
		||||
      conversationId: convo.id,
 | 
			
		||||
      body: 'hello world',
 | 
			
		||||
      type: 'outgoing',
 | 
			
		||||
      source: '+14158675309',
 | 
			
		||||
      received_at: Date.now(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await storage.put('number_id', '+18088888888.1');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('with arbitrary files', () => {
 | 
			
		||||
    it('should render a file view', () => {
 | 
			
		||||
      const attachment = {
 | 
			
		||||
        contentType: 'unused',
 | 
			
		||||
        size: 1232,
 | 
			
		||||
      };
 | 
			
		||||
      const view = new Whisper.AttachmentView({ model: attachment }).render();
 | 
			
		||||
      assert.match(view.el.innerHTML, /fileView/);
 | 
			
		||||
    });
 | 
			
		||||
    it('should display the filename if present', () => {
 | 
			
		||||
      const attachment = {
 | 
			
		||||
        fileName: 'foo.txt',
 | 
			
		||||
        contentType: 'unused',
 | 
			
		||||
        size: 1232,
 | 
			
		||||
      };
 | 
			
		||||
      const view = new Whisper.AttachmentView({ model: attachment }).render();
 | 
			
		||||
      assert.match(view.el.innerHTML, /foo.txt/);
 | 
			
		||||
    });
 | 
			
		||||
    it('should render a file size', () => {
 | 
			
		||||
      const attachment = {
 | 
			
		||||
        size: 1232,
 | 
			
		||||
        contentType: 'unused',
 | 
			
		||||
      };
 | 
			
		||||
      const view = new Whisper.AttachmentView({ model: attachment }).render();
 | 
			
		||||
      assert.match(view.el.innerHTML, /1.2 KB/);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  it('should render an image for images', () => {
 | 
			
		||||
    const now = new Date().getTime();
 | 
			
		||||
    const attachment = { contentType: 'image/png', data: 'grumpy cat' };
 | 
			
		||||
    const view = new Whisper.AttachmentView({
 | 
			
		||||
      model: attachment,
 | 
			
		||||
      timestamp: now,
 | 
			
		||||
    }).render();
 | 
			
		||||
    assert.equal(view.el.firstChild.tagName, 'IMG');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,91 +0,0 @@
 | 
			
		|||
/* global $, Whisper */
 | 
			
		||||
 | 
			
		||||
describe('ConversationSearchView', () => {
 | 
			
		||||
  it('should match partial numbers', () => {
 | 
			
		||||
    const $el = $('<div><div class="new-contact contact hide"></div></div>');
 | 
			
		||||
    const view = new Whisper.ConversationSearchView({
 | 
			
		||||
      el: $el,
 | 
			
		||||
      input: $('<input>'),
 | 
			
		||||
    }).render();
 | 
			
		||||
    const maybeNumbers = [
 | 
			
		||||
      '+1 415',
 | 
			
		||||
      '+1415',
 | 
			
		||||
      '+1415',
 | 
			
		||||
      '415',
 | 
			
		||||
      '(415)',
 | 
			
		||||
      ' (415',
 | 
			
		||||
      '(415) 123 4567',
 | 
			
		||||
      '+1 (415) 123 4567',
 | 
			
		||||
      ' +1 (415) 123 4567',
 | 
			
		||||
      '1 (415) 123 4567',
 | 
			
		||||
      '1 415-123-4567',
 | 
			
		||||
      '415-123-4567',
 | 
			
		||||
    ];
 | 
			
		||||
    maybeNumbers.forEach(n => {
 | 
			
		||||
      assert.ok(view.maybeNumber(n), n);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  describe('Searching for left groups', () => {
 | 
			
		||||
    let convo;
 | 
			
		||||
 | 
			
		||||
    before(() => {
 | 
			
		||||
      convo = new Whisper.ConversationCollection().add({
 | 
			
		||||
        id: '1-search-view',
 | 
			
		||||
        name: 'i left this group',
 | 
			
		||||
        members: [],
 | 
			
		||||
        type: 'group',
 | 
			
		||||
        left: true,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return window.Signal.Data.saveConversation(convo.attributes, {
 | 
			
		||||
        Conversation: Whisper.Conversation,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
    describe('with no messages', () => {
 | 
			
		||||
      let input;
 | 
			
		||||
      let view;
 | 
			
		||||
 | 
			
		||||
      before(done => {
 | 
			
		||||
        input = $('<input>');
 | 
			
		||||
        view = new Whisper.ConversationSearchView({ input }).render();
 | 
			
		||||
        view.$input.val('left');
 | 
			
		||||
        view.filterContacts();
 | 
			
		||||
        view.typeahead_view.collection.on('reset', () => {
 | 
			
		||||
          done();
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
      it('should not surface left groups with no messages', () => {
 | 
			
		||||
        assert.isUndefined(
 | 
			
		||||
          view.typeahead_view.collection.get(convo.id),
 | 
			
		||||
          'got left group'
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
    describe('with messages', () => {
 | 
			
		||||
      let input;
 | 
			
		||||
      let view;
 | 
			
		||||
      before(async () => {
 | 
			
		||||
        input = $('<input>');
 | 
			
		||||
        view = new Whisper.ConversationSearchView({ input }).render();
 | 
			
		||||
        convo.set({ id: '2-search-view', left: false });
 | 
			
		||||
 | 
			
		||||
        await window.Signal.Data.saveConversation(convo.attributes, {
 | 
			
		||||
          Conversation: Whisper.Conversation,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        view.$input.val('left');
 | 
			
		||||
        view.filterContacts();
 | 
			
		||||
 | 
			
		||||
        return new Promise(resolve => {
 | 
			
		||||
          view.typeahead_view.collection.on('reset', resolve);
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
      it('should surface left groups with messages', () => {
 | 
			
		||||
        assert.isDefined(
 | 
			
		||||
          view.typeahead_view.collection.get(convo.id),
 | 
			
		||||
          'got left group'
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,11 @@ describe('InboxView', () => {
 | 
			
		|||
  before(async () => {
 | 
			
		||||
    ConversationController.reset();
 | 
			
		||||
    await ConversationController.load();
 | 
			
		||||
    await textsecure.storage.user.setNumberAndDeviceId(
 | 
			
		||||
      '18005554444',
 | 
			
		||||
      1,
 | 
			
		||||
      'Home Office'
 | 
			
		||||
    );
 | 
			
		||||
    await ConversationController.getOrCreateAndWait(
 | 
			
		||||
      textsecure.storage.user.getNumber(),
 | 
			
		||||
      'private'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,21 +0,0 @@
 | 
			
		|||
/* global Whisper */
 | 
			
		||||
 | 
			
		||||
describe('Threads', () => {
 | 
			
		||||
  it('should be ordered newest to oldest', () => {
 | 
			
		||||
    // Timestamps
 | 
			
		||||
    const today = new Date();
 | 
			
		||||
    const tomorrow = new Date();
 | 
			
		||||
    tomorrow.setDate(today.getDate() + 1);
 | 
			
		||||
 | 
			
		||||
    // Add threads
 | 
			
		||||
    Whisper.Threads.add({ timestamp: today });
 | 
			
		||||
    Whisper.Threads.add({ timestamp: tomorrow });
 | 
			
		||||
 | 
			
		||||
    const { models } = Whisper.Threads;
 | 
			
		||||
    const firstTimestamp = models[0].get('timestamp').getTime();
 | 
			
		||||
    const secondTimestamp = models[1].get('timestamp').getTime();
 | 
			
		||||
 | 
			
		||||
    // Compare timestamps
 | 
			
		||||
    assert(firstTimestamp > secondTimestamp);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -1,139 +0,0 @@
 | 
			
		|||
/* global moment, Whisper */
 | 
			
		||||
 | 
			
		||||
'use strict';
 | 
			
		||||
 | 
			
		||||
describe('TimestampView', () => {
 | 
			
		||||
  it('formats long-ago timestamps correctly', () => {
 | 
			
		||||
    const timestamp = Date.now();
 | 
			
		||||
    const briefView = new Whisper.TimestampView({ brief: true }).render();
 | 
			
		||||
    const extendedView = new Whisper.ExtendedTimestampView().render();
 | 
			
		||||
 | 
			
		||||
    // Helper functions to check absolute and relative timestamps
 | 
			
		||||
 | 
			
		||||
    // Helper to check an absolute TS for an exact match
 | 
			
		||||
    const check = (view, ts, expected) => {
 | 
			
		||||
      const result = view.getRelativeTimeSpanString(ts);
 | 
			
		||||
      assert.strictEqual(result, expected);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Helper to check relative times for an exact match against both views
 | 
			
		||||
    const checkDiff = (secAgo, expectedBrief, expectedExtended) => {
 | 
			
		||||
      check(briefView, timestamp - secAgo * 1000, expectedBrief);
 | 
			
		||||
      check(extendedView, timestamp - secAgo * 1000, expectedExtended);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Helper to check an absolute TS for an exact match against both views
 | 
			
		||||
    const checkAbs = (ts, expectedBrief, expectedExtended) => {
 | 
			
		||||
      if (!expectedExtended) {
 | 
			
		||||
        // eslint-disable-next-line no-param-reassign
 | 
			
		||||
        expectedExtended = expectedBrief;
 | 
			
		||||
      }
 | 
			
		||||
      check(briefView, ts, expectedBrief);
 | 
			
		||||
      check(extendedView, ts, expectedExtended);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Helper to check an absolute TS for a match at the beginning against
 | 
			
		||||
    const checkStartsWith = (view, ts, expected) => {
 | 
			
		||||
      const result = view.getRelativeTimeSpanString(ts);
 | 
			
		||||
      const regexp = new RegExp(`^${expected}`);
 | 
			
		||||
      assert.match(result, regexp);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // check integer timestamp, JS Date object and moment object
 | 
			
		||||
    checkAbs(timestamp, 'now', 'now');
 | 
			
		||||
    checkAbs(new Date(), 'now', 'now');
 | 
			
		||||
    checkAbs(moment(), 'now', 'now');
 | 
			
		||||
 | 
			
		||||
    // check recent timestamps
 | 
			
		||||
    checkDiff(30, 'now', 'now'); // 30 seconds
 | 
			
		||||
    checkDiff(40 * 60, '40 minutes', '40 minutes ago');
 | 
			
		||||
    checkDiff(60 * 60, '1 hour', '1 hour ago');
 | 
			
		||||
    checkDiff(125 * 60, '2 hours', '2 hours ago');
 | 
			
		||||
 | 
			
		||||
    // set to third of month to avoid problems on the 29th/30th/31st
 | 
			
		||||
    const lastMonth = moment()
 | 
			
		||||
      .subtract(1, 'month')
 | 
			
		||||
      .date(3);
 | 
			
		||||
    const months = [
 | 
			
		||||
      'Jan',
 | 
			
		||||
      'Feb',
 | 
			
		||||
      'Mar',
 | 
			
		||||
      'Apr',
 | 
			
		||||
      'May',
 | 
			
		||||
      'Jun',
 | 
			
		||||
      'Jul',
 | 
			
		||||
      'Aug',
 | 
			
		||||
      'Sep',
 | 
			
		||||
      'Oct',
 | 
			
		||||
      'Nov',
 | 
			
		||||
      'Dec',
 | 
			
		||||
    ];
 | 
			
		||||
    check(briefView, lastMonth, `${months[lastMonth.month()]} 3`);
 | 
			
		||||
    checkStartsWith(extendedView, lastMonth, `${months[lastMonth.month()]} 3`);
 | 
			
		||||
 | 
			
		||||
    // subtract 26 hours to be safe in case of DST stuff
 | 
			
		||||
    const yesterday = new Date(timestamp - 26 * 60 * 60 * 1000);
 | 
			
		||||
    const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 | 
			
		||||
    check(briefView, yesterday, daysOfWeek[yesterday.getDay()]);
 | 
			
		||||
    checkStartsWith(extendedView, yesterday, daysOfWeek[yesterday.getDay()]);
 | 
			
		||||
 | 
			
		||||
    // Check something long ago
 | 
			
		||||
    // months are zero-indexed in JS for some reason
 | 
			
		||||
    check(briefView, new Date(2012, 4, 5, 17, 30, 0), 'May 5, 2012');
 | 
			
		||||
    checkStartsWith(
 | 
			
		||||
      extendedView,
 | 
			
		||||
      new Date(2012, 4, 5, 17, 30, 0),
 | 
			
		||||
      'May 5, 2012'
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('updates within a minute reasonable intervals', () => {
 | 
			
		||||
    let view;
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      view = new Whisper.TimestampView();
 | 
			
		||||
    });
 | 
			
		||||
    afterEach(() => {
 | 
			
		||||
      clearTimeout(view.timeout);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('updates timestamps this minute within a minute', () => {
 | 
			
		||||
      const now = Date.now();
 | 
			
		||||
      view.$el.attr('data-timestamp', now - 1000);
 | 
			
		||||
      view.update();
 | 
			
		||||
      assert.isAbove(view.delay, 0); // non zero
 | 
			
		||||
      assert.isBelow(view.delay, 60 * 1000); // < minute
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('updates timestamps from this hour within a minute', () => {
 | 
			
		||||
      const now = Date.now();
 | 
			
		||||
      view.$el.attr('data-timestamp', now - 1000 - 1000 * 60 * 5); // 5 minutes and 1 sec ago
 | 
			
		||||
      view.update();
 | 
			
		||||
      assert.isAbove(view.delay, 0); // non zero
 | 
			
		||||
      assert.isBelow(view.delay, 60 * 1000); // minute
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('updates timestamps from today within an hour', () => {
 | 
			
		||||
      const now = Date.now();
 | 
			
		||||
      view.$el.attr('data-timestamp', now - 1000 - 1000 * 60 * 60 * 5); // 5 hours and 1 sec ago
 | 
			
		||||
      view.update();
 | 
			
		||||
      assert.isAbove(view.delay, 60 * 1000); // minute
 | 
			
		||||
      assert.isBelow(view.delay, 60 * 60 * 1000); // hour
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('updates timestamps from this week within a day', () => {
 | 
			
		||||
      const now = Date.now();
 | 
			
		||||
      view.$el.attr('data-timestamp', now - 1000 - 6 * 24 * 60 * 60 * 1000); // 6 days and 1 sec ago
 | 
			
		||||
      view.update();
 | 
			
		||||
      assert.isAbove(view.delay, 60 * 60 * 1000); // hour
 | 
			
		||||
      assert.isBelow(view.delay, 36 * 60 * 60 * 1000); // day and a half
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('does not updates very old timestamps', () => {
 | 
			
		||||
      const now = Date.now();
 | 
			
		||||
      // return falsey value for long ago dates that don't update
 | 
			
		||||
      view.$el.attr('data-timestamp', now - 8 * 24 * 60 * 60 * 1000);
 | 
			
		||||
      view.update();
 | 
			
		||||
      assert.notOk(view.delay);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ export const show = (element: HTMLElement): void => {
 | 
			
		|||
  const container: HTMLDivElement | null = document.querySelector(
 | 
			
		||||
    '.lightbox-container'
 | 
			
		||||
  );
 | 
			
		||||
  if (container === null) {
 | 
			
		||||
  if (!container) {
 | 
			
		||||
    throw new TypeError("'.lightbox-container' is required");
 | 
			
		||||
  }
 | 
			
		||||
  // tslint:disable-next-line:no-inner-html
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ export const hide = (): void => {
 | 
			
		|||
  const container: HTMLDivElement | null = document.querySelector(
 | 
			
		||||
    '.lightbox-container'
 | 
			
		||||
  );
 | 
			
		||||
  if (container === null) {
 | 
			
		||||
  if (!container) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  // tslint:disable-next-line:no-inner-html
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,13 +2,13 @@ import React from 'react';
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { getInitials } from '../util/getInitials';
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  avatarPath?: string;
 | 
			
		||||
  color?: string;
 | 
			
		||||
  conversationType: 'group' | 'direct';
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  noteToSelf?: boolean;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  phoneNumber?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -44,9 +44,8 @@ export class Avatar extends React.Component<Props, State> {
 | 
			
		|||
  public renderImage() {
 | 
			
		||||
    const { avatarPath, i18n, name, phoneNumber, profileName } = this.props;
 | 
			
		||||
    const { imageBroken } = this.state;
 | 
			
		||||
    const hasImage = avatarPath && !imageBroken;
 | 
			
		||||
 | 
			
		||||
    if (!hasImage) {
 | 
			
		||||
    if (!avatarPath || imageBroken) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,13 +3,13 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import * as GoogleChrome from '../util/GoogleChrome';
 | 
			
		||||
 | 
			
		||||
import { AttachmentType } from './conversation/types';
 | 
			
		||||
import { AttachmentType } from '../types/Attachment';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  attachment: AttachmentType;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  url: string;
 | 
			
		||||
  caption?: string;
 | 
			
		||||
  onSave?: (caption: string) => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -21,15 +21,15 @@ interface State {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export class CaptionEditor extends React.Component<Props, State> {
 | 
			
		||||
  private handleKeyUpBound: (
 | 
			
		||||
  private readonly handleKeyUpBound: (
 | 
			
		||||
    event: React.KeyboardEvent<HTMLInputElement>
 | 
			
		||||
  ) => void;
 | 
			
		||||
  private setFocusBound: () => void;
 | 
			
		||||
  // TypeScript doesn't like our React.Ref typing here, so we omit it
 | 
			
		||||
  private captureRefBound: () => void;
 | 
			
		||||
  private onChangeBound: () => void;
 | 
			
		||||
  private onSaveBound: () => void;
 | 
			
		||||
  private inputRef: React.Ref<HTMLInputElement> | null;
 | 
			
		||||
  private readonly setFocusBound: () => void;
 | 
			
		||||
  private readonly onChangeBound: (
 | 
			
		||||
    event: React.FormEvent<HTMLInputElement>
 | 
			
		||||
  ) => void;
 | 
			
		||||
  private readonly onSaveBound: () => void;
 | 
			
		||||
  private readonly inputRef: React.RefObject<HTMLInputElement>;
 | 
			
		||||
 | 
			
		||||
  constructor(props: Props) {
 | 
			
		||||
    super(props);
 | 
			
		||||
| 
						 | 
				
			
			@ -41,10 +41,16 @@ export class CaptionEditor extends React.Component<Props, State> {
 | 
			
		|||
 | 
			
		||||
    this.handleKeyUpBound = this.handleKeyUp.bind(this);
 | 
			
		||||
    this.setFocusBound = this.setFocus.bind(this);
 | 
			
		||||
    this.captureRefBound = this.captureRef.bind(this);
 | 
			
		||||
    this.onChangeBound = this.onChange.bind(this);
 | 
			
		||||
    this.onSaveBound = this.onSave.bind(this);
 | 
			
		||||
    this.inputRef = null;
 | 
			
		||||
    this.inputRef = React.createRef();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public componentDidMount() {
 | 
			
		||||
    // Forcing focus after a delay due to some focus contention with ConversationView
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.setFocus();
 | 
			
		||||
    }, 200);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
 | 
			
		||||
| 
						 | 
				
			
			@ -61,21 +67,11 @@ export class CaptionEditor extends React.Component<Props, State> {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  public setFocus() {
 | 
			
		||||
    if (this.inputRef) {
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      this.inputRef.focus();
 | 
			
		||||
    if (this.inputRef.current) {
 | 
			
		||||
      this.inputRef.current.focus();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public captureRef(ref: React.Ref<HTMLInputElement>) {
 | 
			
		||||
    this.inputRef = ref;
 | 
			
		||||
 | 
			
		||||
    // Forcing focus after a delay due to some focus contention with ConversationView
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.setFocus();
 | 
			
		||||
    }, 200);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public onSave() {
 | 
			
		||||
    const { onSave } = this.props;
 | 
			
		||||
    const { caption } = this.state;
 | 
			
		||||
| 
						 | 
				
			
			@ -124,6 +120,7 @@ export class CaptionEditor extends React.Component<Props, State> {
 | 
			
		|||
  public render() {
 | 
			
		||||
    const { i18n, close } = this.props;
 | 
			
		||||
    const { caption } = this.state;
 | 
			
		||||
    const onKeyUp = close ? this.handleKeyUpBound : undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
| 
						 | 
				
			
			@ -143,12 +140,12 @@ export class CaptionEditor extends React.Component<Props, State> {
 | 
			
		|||
          <div className="module-caption-editor__input-container">
 | 
			
		||||
            <input
 | 
			
		||||
              type="text"
 | 
			
		||||
              ref={this.captureRefBound}
 | 
			
		||||
              ref={this.inputRef}
 | 
			
		||||
              value={caption}
 | 
			
		||||
              maxLength={200}
 | 
			
		||||
              placeholder={i18n('addACaption')}
 | 
			
		||||
              className="module-caption-editor__caption-input"
 | 
			
		||||
              onKeyUp={close ? this.handleKeyUpBound : undefined}
 | 
			
		||||
              onKeyUp={onKeyUp}
 | 
			
		||||
              onChange={this.onChangeBound}
 | 
			
		||||
            />
 | 
			
		||||
            {caption ? (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,17 +4,17 @@ import classNames from 'classnames';
 | 
			
		|||
import { Avatar } from './Avatar';
 | 
			
		||||
import { Emojify } from './conversation/Emojify';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  phoneNumber: string;
 | 
			
		||||
  isMe?: boolean;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  color?: string;
 | 
			
		||||
  color: string;
 | 
			
		||||
  verified: boolean;
 | 
			
		||||
  profileName?: string;
 | 
			
		||||
  avatarPath?: string;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@
 | 
			
		|||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <ConversationListItem
 | 
			
		||||
    id="conversationId1"
 | 
			
		||||
    name="Someone 🔥 Somewhere"
 | 
			
		||||
    conversationType={'direct'}
 | 
			
		||||
    phoneNumber="(202) 555-0011"
 | 
			
		||||
| 
						 | 
				
			
			@ -12,7 +13,7 @@
 | 
			
		|||
      text: "What's going on?",
 | 
			
		||||
      status: 'sent',
 | 
			
		||||
    }}
 | 
			
		||||
    onClick={() => console.log('onClick')}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
| 
						 | 
				
			
			@ -23,6 +24,7 @@
 | 
			
		|||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <ConversationListItem
 | 
			
		||||
    id="conversationId1"
 | 
			
		||||
    phoneNumber="(202) 555-0011"
 | 
			
		||||
    conversationType={'direct'}
 | 
			
		||||
    name="Mr. Fire🔥"
 | 
			
		||||
| 
						 | 
				
			
			@ -32,7 +34,7 @@
 | 
			
		|||
      text: 'Just a second',
 | 
			
		||||
      status: 'read',
 | 
			
		||||
    }}
 | 
			
		||||
    onClick={() => console.log('onClick')}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +45,7 @@
 | 
			
		|||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <ConversationListItem
 | 
			
		||||
    id="conversationId1"
 | 
			
		||||
    isMe={true}
 | 
			
		||||
    phoneNumber="(202) 555-0011"
 | 
			
		||||
    conversationType={'direct'}
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +56,7 @@
 | 
			
		|||
      text: 'Just a second',
 | 
			
		||||
      status: 'read',
 | 
			
		||||
    }}
 | 
			
		||||
    onClick={() => console.log('onClick')}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +68,7 @@
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Mr. Fire🔥"
 | 
			
		||||
| 
						 | 
				
			
			@ -74,10 +78,11 @@
 | 
			
		|||
        text: 'Sending',
 | 
			
		||||
        status: 'sending',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Mr. Fire🔥"
 | 
			
		||||
| 
						 | 
				
			
			@ -87,10 +92,11 @@
 | 
			
		|||
        text: 'Sent',
 | 
			
		||||
        status: 'sent',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId3"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Mr. Fire🔥"
 | 
			
		||||
| 
						 | 
				
			
			@ -100,10 +106,11 @@
 | 
			
		|||
        text: 'Delivered',
 | 
			
		||||
        status: 'delivered',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId4"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Mr. Fire🔥"
 | 
			
		||||
| 
						 | 
				
			
			@ -113,10 +120,11 @@
 | 
			
		|||
        text: 'Read',
 | 
			
		||||
        status: 'read',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId5"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Mr. Fire🔥"
 | 
			
		||||
| 
						 | 
				
			
			@ -126,7 +134,7 @@
 | 
			
		|||
        text: 'Error',
 | 
			
		||||
        status: 'error',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -139,17 +147,19 @@
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      unreadCount={4}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
      isTyping={true}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      unreadCount={4}
 | 
			
		||||
| 
						 | 
				
			
			@ -158,7 +168,7 @@
 | 
			
		|||
      lastMessage={{
 | 
			
		||||
        status: 'read',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -173,6 +183,7 @@
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      unreadCount={4}
 | 
			
		||||
| 
						 | 
				
			
			@ -180,10 +191,11 @@
 | 
			
		|||
      lastMessage={{
 | 
			
		||||
        text: 'Hey there!',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      unreadCount={10}
 | 
			
		||||
| 
						 | 
				
			
			@ -191,10 +203,11 @@
 | 
			
		|||
      lastMessage={{
 | 
			
		||||
        text: 'Hey there!',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId3"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      unreadCount={250}
 | 
			
		||||
| 
						 | 
				
			
			@ -202,7 +215,7 @@
 | 
			
		|||
      lastMessage={{
 | 
			
		||||
        text: 'Hey there!',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -214,6 +227,7 @@
 | 
			
		|||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <ConversationListItem
 | 
			
		||||
    id="conversationId1"
 | 
			
		||||
    phoneNumber="(202) 555-0011"
 | 
			
		||||
    conversationType={'direct'}
 | 
			
		||||
    isSelected={true}
 | 
			
		||||
| 
						 | 
				
			
			@ -221,7 +235,7 @@
 | 
			
		|||
    lastMessage={{
 | 
			
		||||
      text: 'Hey there!',
 | 
			
		||||
    }}
 | 
			
		||||
    onClick={() => console.log('onClick')}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
| 
						 | 
				
			
			@ -235,23 +249,25 @@ We don't want Jumbomoji or links.
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: 'Download at http://signal.org',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: '🔥',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -266,6 +282,7 @@ We only show one line.
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Long contact name. Esquire. The third. And stuff. And more! And more!"
 | 
			
		||||
| 
						 | 
				
			
			@ -273,10 +290,11 @@ We only show one line.
 | 
			
		|||
      lastMessage={{
 | 
			
		||||
        text: 'Normal message',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -284,10 +302,11 @@ We only show one line.
 | 
			
		|||
        text:
 | 
			
		||||
          "Long line. This is a really really really long line. Really really long. Because that's just how it is",
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId3"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -296,11 +315,12 @@ We only show one line.
 | 
			
		|||
          "Long line. This is a really really really long line. Really really long. Because that's just how it is",
 | 
			
		||||
        status: 'read',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId4"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -309,10 +329,11 @@ We only show one line.
 | 
			
		|||
        text:
 | 
			
		||||
          "Long line. This is a really really really long line. Really really long. Because that's just how it is",
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId5"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -320,10 +341,11 @@ We only show one line.
 | 
			
		|||
        text:
 | 
			
		||||
          "Many lines. This is a many-line message.\nLine 2 is really exciting but it shouldn't be seen.\nLine three is even better.\nLine 4, well.",
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId6"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -332,7 +354,7 @@ We only show one line.
 | 
			
		|||
          "Many lines. This is a many-line message.\nLine 2 is really exciting but it shouldn't be seen.\nLine three is even better.\nLine 4, well.",
 | 
			
		||||
        status: 'delivered',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -347,6 +369,7 @@ On platforms that show scrollbars all the time, this is true all the time.
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div style={{ width: '280px' }}>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      name="Long contact name. Esquire. The third. And stuff. And more! And more!"
 | 
			
		||||
| 
						 | 
				
			
			@ -354,10 +377,11 @@ On platforms that show scrollbars all the time, this is true all the time.
 | 
			
		|||
      lastMessage={{
 | 
			
		||||
        text: 'Normal message',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -365,7 +389,7 @@ On platforms that show scrollbars all the time, this is true all the time.
 | 
			
		|||
        text:
 | 
			
		||||
          "Long line. This is a really really really long line. Really really long. Because that's just how it is",
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -378,43 +402,47 @@ On platforms that show scrollbars all the time, this is true all the time.
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: 'Five hours ago',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 24 * 60 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: 'One day ago',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId3"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 7 * 24 * 60 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: 'One week ago',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId4"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 365 * 24 * 60 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: 'One year ago',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -427,26 +455,29 @@ On platforms that show scrollbars all the time, this is true all the time.
 | 
			
		|||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <div>
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId1"
 | 
			
		||||
      name="John"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={null}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: 'Missing last updated',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId2"
 | 
			
		||||
      name="Missing message"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
      lastMessage={{
 | 
			
		||||
        text: null,
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
    <ConversationListItem
 | 
			
		||||
      id="conversationId3"
 | 
			
		||||
      phoneNumber="(202) 555-0011"
 | 
			
		||||
      conversationType={'direct'}
 | 
			
		||||
      lastUpdated={Date.now() - 5 * 60 * 1000}
 | 
			
		||||
| 
						 | 
				
			
			@ -454,7 +485,7 @@ On platforms that show scrollbars all the time, this is true all the time.
 | 
			
		|||
        text: null,
 | 
			
		||||
        status: 'sent',
 | 
			
		||||
      }}
 | 
			
		||||
      onClick={() => console.log('onClick')}
 | 
			
		||||
      onClick={result => console.log('onClick', result)}
 | 
			
		||||
      i18n={util.i18n}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,14 +7,15 @@ import { Timestamp } from './conversation/Timestamp';
 | 
			
		|||
import { ContactName } from './conversation/ContactName';
 | 
			
		||||
import { TypingAnimation } from './conversation/TypingAnimation';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
export type PropsData = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  phoneNumber: string;
 | 
			
		||||
  color?: string;
 | 
			
		||||
  profileName?: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  color?: string;
 | 
			
		||||
  conversationType: 'group' | 'direct';
 | 
			
		||||
  type: 'group' | 'direct';
 | 
			
		||||
  avatarPath?: string;
 | 
			
		||||
  isMe: boolean;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -27,17 +28,21 @@ interface Props {
 | 
			
		|||
    status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
 | 
			
		||||
    text: string;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}
 | 
			
		||||
type PropsHousekeeping = {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onClick?: (id: string) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class ConversationListItem extends React.Component<Props> {
 | 
			
		||||
type Props = PropsData & PropsHousekeeping;
 | 
			
		||||
 | 
			
		||||
export class ConversationListItem extends React.PureComponent<Props> {
 | 
			
		||||
  public renderAvatar() {
 | 
			
		||||
    const {
 | 
			
		||||
      avatarPath,
 | 
			
		||||
      color,
 | 
			
		||||
      conversationType,
 | 
			
		||||
      type,
 | 
			
		||||
      i18n,
 | 
			
		||||
      isMe,
 | 
			
		||||
      name,
 | 
			
		||||
| 
						 | 
				
			
			@ -51,7 +56,7 @@ export class ConversationListItem extends React.Component<Props> {
 | 
			
		|||
          avatarPath={avatarPath}
 | 
			
		||||
          color={color}
 | 
			
		||||
          noteToSelf={isMe}
 | 
			
		||||
          conversationType={conversationType}
 | 
			
		||||
          conversationType={type}
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
          name={name}
 | 
			
		||||
          phoneNumber={phoneNumber}
 | 
			
		||||
| 
						 | 
				
			
			@ -130,10 +135,10 @@ export class ConversationListItem extends React.Component<Props> {
 | 
			
		|||
 | 
			
		||||
  public renderMessage() {
 | 
			
		||||
    const { lastMessage, isTyping, unreadCount, i18n } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (!lastMessage && !isTyping) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    const text = lastMessage && lastMessage.text ? lastMessage.text : '';
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-conversation-list-item__message">
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +154,7 @@ export class ConversationListItem extends React.Component<Props> {
 | 
			
		|||
            <TypingAnimation i18n={i18n} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <MessageBody
 | 
			
		||||
              text={lastMessage && lastMessage.text ? lastMessage.text : ''}
 | 
			
		||||
              text={text}
 | 
			
		||||
              disableJumbomoji={true}
 | 
			
		||||
              disableLinks={true}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
| 
						 | 
				
			
			@ -171,12 +176,16 @@ export class ConversationListItem extends React.Component<Props> {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { unreadCount, onClick, isSelected } = this.props;
 | 
			
		||||
    const { unreadCount, onClick, id, isSelected } = this.props;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        role="button"
 | 
			
		||||
        onClick={onClick}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          if (onClick) {
 | 
			
		||||
            onClick(id);
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        className={classNames(
 | 
			
		||||
          'module-conversation-list-item',
 | 
			
		||||
          unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,15 +1,15 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { Localizer, RenderTextCallback } from '../types/Util';
 | 
			
		||||
import { LocalizerType, RenderTextCallbackType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
type FullJSX = Array<JSX.Element | string> | JSX.Element | string;
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  /** The translation string id */
 | 
			
		||||
  id: string;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  components?: Array<FullJSX>;
 | 
			
		||||
  renderText?: RenderTextCallback;
 | 
			
		||||
  renderText?: RenderTextCallbackType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Intl extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ export class Intl extends React.Component<Props> {
 | 
			
		|||
    renderText: ({ text }) => text,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  public getComponent(index: number): FullJSX | null {
 | 
			
		||||
  public getComponent(index: number): FullJSX | undefined {
 | 
			
		||||
    const { id, components } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (!components || !components.length || components.length <= index) {
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ export class Intl extends React.Component<Props> {
 | 
			
		|||
        `Error: Intl missing provided components for id ${id}, index ${index}`
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return null;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return components[index];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										168
									
								
								ts/components/LeftPane.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								ts/components/LeftPane.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,168 @@
 | 
			
		|||
#### With search results
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
window.searchResults = {};
 | 
			
		||||
window.searchResults.conversations = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 'convo1',
 | 
			
		||||
    name: 'Everyone 🌆',
 | 
			
		||||
    conversationType: 'group',
 | 
			
		||||
    phoneNumber: '(202) 555-0011',
 | 
			
		||||
    avatarPath: util.landscapeGreenObjectUrl,
 | 
			
		||||
    lastUpdated: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    lastMessage: {
 | 
			
		||||
      text: 'The rabbit hopped silently in the night.',
 | 
			
		||||
      status: 'sent',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'convo2',
 | 
			
		||||
    name: 'Everyone Else 🔥',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0012',
 | 
			
		||||
    avatarPath: util.landscapePurpleObjectUrl,
 | 
			
		||||
    lastUpdated: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    lastMessage: {
 | 
			
		||||
      text: "What's going on?",
 | 
			
		||||
      status: 'error',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'convo3',
 | 
			
		||||
    name: 'John the Turtle',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0021',
 | 
			
		||||
    lastUpdated: Date.now() - 24 * 60 * 60 * 1000,
 | 
			
		||||
    lastMessage: {
 | 
			
		||||
      text: 'I dunno',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'convo4',
 | 
			
		||||
    name: 'The Fly',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0022',
 | 
			
		||||
    avatarPath: util.pngObjectUrl,
 | 
			
		||||
    lastUpdated: Date.now(),
 | 
			
		||||
    lastMessage: {
 | 
			
		||||
      text: 'Gimme!',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
window.searchResults.contacts = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 'contact1',
 | 
			
		||||
    name: 'The one Everyone',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0013',
 | 
			
		||||
    avatarPath: util.gifObjectUrl,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'contact2',
 | 
			
		||||
    e: 'No likey everyone',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0014',
 | 
			
		||||
    color: 'red',
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
window.searchResults.messages = [
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      name: 'Mr. Fire 🔥',
 | 
			
		||||
      phoneNumber: '(202) 555-0015',
 | 
			
		||||
    },
 | 
			
		||||
    id: '1-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: '(202) 555-0015',
 | 
			
		||||
    receivedAt: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    snippet: '<<left>>Everyone<<right>>! Get in!',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      name: 'Jon ❄️',
 | 
			
		||||
      phoneNumber: '(202) 555-0016',
 | 
			
		||||
      color: 'green',
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    },
 | 
			
		||||
    id: '2-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: '(202) 555-0016',
 | 
			
		||||
    snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
 | 
			
		||||
    receivedAt: Date.now() - 20 * 60 * 1000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      name: 'Someone',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      color: 'green',
 | 
			
		||||
      avatarPath: util.pngObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      name: "Y'all 🌆",
 | 
			
		||||
    },
 | 
			
		||||
    id: '3-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: 'EveryoneGroupID',
 | 
			
		||||
    snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
 | 
			
		||||
    receivedAt: Date.now() - 24 * 60 * 1000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      name: "Y'all 🌆",
 | 
			
		||||
    },
 | 
			
		||||
    id: '4-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: 'EveryoneGroupID',
 | 
			
		||||
    snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
 | 
			
		||||
    receivedAt: Date.now() - 24 * 60 * 1000,
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
 | 
			
		||||
  <LeftPane
 | 
			
		||||
    searchResults={window.searchResults}
 | 
			
		||||
    openConversation={result => console.log('openConversation', result)}
 | 
			
		||||
    openMessage={result => console.log('onClickMessage', result)}
 | 
			
		||||
    renderMainHeader={() => (
 | 
			
		||||
      <MainHeader
 | 
			
		||||
        searchTerm="Hi there!"
 | 
			
		||||
        search={result => console.log('search', result)}
 | 
			
		||||
        updateSearch={result => console.log('updateSearch', result)}
 | 
			
		||||
        clearSearch={result => console.log('clearSearch', result)}
 | 
			
		||||
        i18n={util.i18n}
 | 
			
		||||
      />
 | 
			
		||||
    )}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>;
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With just conversations
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
 | 
			
		||||
  <LeftPane
 | 
			
		||||
    conversations={window.searchResults.conversations}
 | 
			
		||||
    openConversation={result => console.log('openConversation', result)}
 | 
			
		||||
    openMessage={result => console.log('onClickMessage', result)}
 | 
			
		||||
    renderMainHeader={() => (
 | 
			
		||||
      <MainHeader
 | 
			
		||||
        searchTerm="Hi there!"
 | 
			
		||||
        search={result => console.log('search', result)}
 | 
			
		||||
        updateSearch={result => console.log('updateSearch', result)}
 | 
			
		||||
        clearSearch={result => console.log('clearSearch', result)}
 | 
			
		||||
        i18n={util.i18n}
 | 
			
		||||
      />
 | 
			
		||||
    )}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										71
									
								
								ts/components/LeftPane.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								ts/components/LeftPane.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  ConversationListItem,
 | 
			
		||||
  PropsData as ConversationListItemPropsType,
 | 
			
		||||
} from './ConversationListItem';
 | 
			
		||||
import {
 | 
			
		||||
  PropsData as SearchResultsProps,
 | 
			
		||||
  SearchResults,
 | 
			
		||||
} from './SearchResults';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  conversations?: Array<ConversationListItemPropsType>;
 | 
			
		||||
  searchResults?: SearchResultsProps;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
 | 
			
		||||
  // Action Creators
 | 
			
		||||
  startNewConversation: () => void;
 | 
			
		||||
  openConversationInternal: (id: string, messageId?: string) => void;
 | 
			
		||||
 | 
			
		||||
  // Render Props
 | 
			
		||||
  renderMainHeader: () => JSX.Element;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class LeftPane extends React.Component<Props> {
 | 
			
		||||
  public renderList() {
 | 
			
		||||
    const {
 | 
			
		||||
      conversations,
 | 
			
		||||
      i18n,
 | 
			
		||||
      openConversationInternal,
 | 
			
		||||
      startNewConversation,
 | 
			
		||||
      searchResults,
 | 
			
		||||
    } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (searchResults) {
 | 
			
		||||
      return (
 | 
			
		||||
        <SearchResults
 | 
			
		||||
          {...searchResults}
 | 
			
		||||
          openConversation={openConversationInternal}
 | 
			
		||||
          startNewConversation={startNewConversation}
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-left-pane__list">
 | 
			
		||||
        {(conversations || []).map(conversation => (
 | 
			
		||||
          <ConversationListItem
 | 
			
		||||
            key={conversation.phoneNumber}
 | 
			
		||||
            {...conversation}
 | 
			
		||||
            onClick={openConversationInternal}
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { renderMainHeader } = this.props;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-left-pane">
 | 
			
		||||
        <div className="module-left-pane__header">{renderMainHeader()}</div>
 | 
			
		||||
        {this.renderList()}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -8,7 +8,7 @@ import is from '@sindresorhus/is';
 | 
			
		|||
import * as GoogleChrome from '../util/GoogleChrome';
 | 
			
		||||
import * as MIME from '../types/MIME';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
const Colors = {
 | 
			
		||||
  TEXT_SECONDARY: '#bbb',
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ const colorSVG = (url: string, color: string) => {
 | 
			
		|||
interface Props {
 | 
			
		||||
  close: () => void;
 | 
			
		||||
  contentType: MIME.MIMEType | undefined;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  objectURL: string;
 | 
			
		||||
  caption?: string;
 | 
			
		||||
  onNext?: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -164,17 +164,17 @@ const Icon = ({
 | 
			
		|||
);
 | 
			
		||||
 | 
			
		||||
export class Lightbox extends React.Component<Props> {
 | 
			
		||||
  private containerRef: HTMLDivElement | null = null;
 | 
			
		||||
  private videoRef: HTMLVideoElement | null = null;
 | 
			
		||||
 | 
			
		||||
  private captureVideoBound: (element: HTMLVideoElement) => void;
 | 
			
		||||
  private playVideoBound: () => void;
 | 
			
		||||
  private readonly containerRef: React.RefObject<HTMLDivElement>;
 | 
			
		||||
  private readonly videoRef: React.RefObject<HTMLVideoElement>;
 | 
			
		||||
  private readonly playVideoBound: () => void;
 | 
			
		||||
 | 
			
		||||
  constructor(props: Props) {
 | 
			
		||||
    super(props);
 | 
			
		||||
 | 
			
		||||
    this.captureVideoBound = this.captureVideo.bind(this);
 | 
			
		||||
    this.playVideoBound = this.playVideo.bind(this);
 | 
			
		||||
 | 
			
		||||
    this.videoRef = React.createRef();
 | 
			
		||||
    this.containerRef = React.createRef();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public componentDidMount() {
 | 
			
		||||
| 
						 | 
				
			
			@ -189,20 +189,21 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
    document.removeEventListener('keyup', this.onKeyUp, useCapture);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public captureVideo(element: HTMLVideoElement) {
 | 
			
		||||
    this.videoRef = element;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public playVideo() {
 | 
			
		||||
    if (!this.videoRef) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.videoRef.paused) {
 | 
			
		||||
    const { current } = this.videoRef;
 | 
			
		||||
    if (!current) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (current.paused) {
 | 
			
		||||
      // tslint:disable-next-line no-floating-promises
 | 
			
		||||
      this.videoRef.play();
 | 
			
		||||
      current.play();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.videoRef.pause();
 | 
			
		||||
      current.pause();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -221,7 +222,7 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
      <div
 | 
			
		||||
        style={styles.container}
 | 
			
		||||
        onClick={this.onContainerClick}
 | 
			
		||||
        ref={this.setContainerRef}
 | 
			
		||||
        ref={this.containerRef}
 | 
			
		||||
        role="dialog"
 | 
			
		||||
      >
 | 
			
		||||
        <div style={styles.mainContainer}>
 | 
			
		||||
| 
						 | 
				
			
			@ -259,14 +260,14 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private renderObject = ({
 | 
			
		||||
  private readonly renderObject = ({
 | 
			
		||||
    objectURL,
 | 
			
		||||
    contentType,
 | 
			
		||||
    i18n,
 | 
			
		||||
  }: {
 | 
			
		||||
    objectURL: string;
 | 
			
		||||
    contentType: MIME.MIMEType;
 | 
			
		||||
    i18n: Localizer;
 | 
			
		||||
    i18n: LocalizerType;
 | 
			
		||||
  }) => {
 | 
			
		||||
    const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
 | 
			
		||||
    if (isImageTypeSupported) {
 | 
			
		||||
| 
						 | 
				
			
			@ -285,7 +286,7 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
      return (
 | 
			
		||||
        <video
 | 
			
		||||
          role="button"
 | 
			
		||||
          ref={this.captureVideoBound}
 | 
			
		||||
          ref={this.videoRef}
 | 
			
		||||
          onClick={this.playVideoBound}
 | 
			
		||||
          controls={true}
 | 
			
		||||
          style={styles.object}
 | 
			
		||||
| 
						 | 
				
			
			@ -301,12 +302,11 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
    const isUnsupportedVideoType =
 | 
			
		||||
      !isVideoTypeSupported && MIME.isVideo(contentType);
 | 
			
		||||
    if (isUnsupportedImageType || isUnsupportedVideoType) {
 | 
			
		||||
      return (
 | 
			
		||||
        <Icon
 | 
			
		||||
          url={isUnsupportedVideoType ? 'images/video.svg' : 'images/image.svg'}
 | 
			
		||||
          onClick={this.onObjectClick}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
      const iconUrl = isUnsupportedVideoType
 | 
			
		||||
        ? 'images/video.svg'
 | 
			
		||||
        : 'images/image.svg';
 | 
			
		||||
 | 
			
		||||
      return <Icon url={iconUrl} onClick={this.onObjectClick} />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // tslint:disable-next-line no-console
 | 
			
		||||
| 
						 | 
				
			
			@ -315,11 +315,7 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
    return <Icon onClick={this.onObjectClick} url="images/file.svg" />;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private setContainerRef = (value: HTMLDivElement) => {
 | 
			
		||||
    this.containerRef = value;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private onClose = () => {
 | 
			
		||||
  private readonly onClose = () => {
 | 
			
		||||
    const { close } = this.props;
 | 
			
		||||
    if (!close) {
 | 
			
		||||
      return;
 | 
			
		||||
| 
						 | 
				
			
			@ -328,7 +324,7 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
    close();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private onKeyUp = (event: KeyboardEvent) => {
 | 
			
		||||
  private readonly onKeyUp = (event: KeyboardEvent) => {
 | 
			
		||||
    const { onNext, onPrevious } = this.props;
 | 
			
		||||
    switch (event.key) {
 | 
			
		||||
      case 'Escape':
 | 
			
		||||
| 
						 | 
				
			
			@ -351,14 +347,16 @@ export class Lightbox extends React.Component<Props> {
 | 
			
		|||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
 | 
			
		||||
    if (event.target !== this.containerRef) {
 | 
			
		||||
  private readonly onContainerClick = (
 | 
			
		||||
    event: React.MouseEvent<HTMLDivElement>
 | 
			
		||||
  ) => {
 | 
			
		||||
    if (this.containerRef && event.target !== this.containerRef.current) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.onClose();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private onObjectClick = (
 | 
			
		||||
  private readonly onObjectClick = (
 | 
			
		||||
    event: React.MouseEvent<HTMLImageElement | HTMLDivElement>
 | 
			
		||||
  ) => {
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,9 +6,9 @@ import React from 'react';
 | 
			
		|||
import * as MIME from '../types/MIME';
 | 
			
		||||
import { Lightbox } from './Lightbox';
 | 
			
		||||
import { Message } from './conversation/media-gallery/types/Message';
 | 
			
		||||
import { AttachmentType } from './conversation/types';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { AttachmentType } from '../types/Attachment';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
export interface MediaItemType {
 | 
			
		||||
  objectURL?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +21,7 @@ export interface MediaItemType {
 | 
			
		|||
 | 
			
		||||
interface Props {
 | 
			
		||||
  close: () => void;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  media: Array<MediaItemType>;
 | 
			
		||||
  onSave?: (
 | 
			
		||||
    options: { attachment: AttachmentType; message: Message; index: number }
 | 
			
		||||
| 
						 | 
				
			
			@ -61,27 +61,30 @@ export class LightboxGallery extends React.Component<Props, State> {
 | 
			
		|||
    const objectURL = selectedMedia.objectURL || 'images/alert-outline.svg';
 | 
			
		||||
    const { attachment } = selectedMedia;
 | 
			
		||||
 | 
			
		||||
    const saveCallback = onSave ? this.handleSave : undefined;
 | 
			
		||||
    const captionCallback = attachment ? attachment.caption : undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Lightbox
 | 
			
		||||
        close={close}
 | 
			
		||||
        onPrevious={onPrevious}
 | 
			
		||||
        onNext={onNext}
 | 
			
		||||
        onSave={onSave ? this.handleSave : undefined}
 | 
			
		||||
        onSave={saveCallback}
 | 
			
		||||
        objectURL={objectURL}
 | 
			
		||||
        caption={attachment ? attachment.caption : undefined}
 | 
			
		||||
        caption={captionCallback}
 | 
			
		||||
        contentType={selectedMedia.contentType}
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private handlePrevious = () => {
 | 
			
		||||
  private readonly handlePrevious = () => {
 | 
			
		||||
    this.setState(prevState => ({
 | 
			
		||||
      selectedIndex: Math.max(prevState.selectedIndex - 1, 0),
 | 
			
		||||
    }));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private handleNext = () => {
 | 
			
		||||
  private readonly handleNext = () => {
 | 
			
		||||
    this.setState((prevState, props) => ({
 | 
			
		||||
      selectedIndex: Math.min(
 | 
			
		||||
        prevState.selectedIndex + 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +93,7 @@ export class LightboxGallery extends React.Component<Props, State> {
 | 
			
		|||
    }));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private handleSave = () => {
 | 
			
		||||
  private readonly handleSave = () => {
 | 
			
		||||
    const { media, onSave } = this.props;
 | 
			
		||||
    if (!onSave) {
 | 
			
		||||
      return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,65 @@
 | 
			
		|||
Note that this component is controlled, so the text in the search box will only update
 | 
			
		||||
if the parent of this component feeds the updated `searchTerm` back.
 | 
			
		||||
 | 
			
		||||
#### With image
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MainHeader avatarPath={util.gifObjectUrl} i18n={util.i18n} />
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MainHeader
 | 
			
		||||
    searchTerm=""
 | 
			
		||||
    avatarPath={util.gifObjectUrl}
 | 
			
		||||
    search={text => console.log('search', text)}
 | 
			
		||||
    updateSearchTerm={text => console.log('updateSearchTerm', text)}
 | 
			
		||||
    clearSearch={() => console.log('clearSearch')}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Just name
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MainHeader name="John Smith" color="purple" i18n={util.i18n} />
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MainHeader
 | 
			
		||||
    searchTerm=""
 | 
			
		||||
    name="John Smith"
 | 
			
		||||
    color="purple"
 | 
			
		||||
    search={text => console.log('search', text)}
 | 
			
		||||
    updateSearchTerm={text => console.log('updateSearchTerm', text)}
 | 
			
		||||
    clearSearch={() => console.log('clearSearch')}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Just phone number
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MainHeader
 | 
			
		||||
    searchTerm=""
 | 
			
		||||
    phoneNumber="+15553004000"
 | 
			
		||||
    color="green"
 | 
			
		||||
    search={text => console.log('search', text)}
 | 
			
		||||
    updateSearchTerm={text => console.log('updateSearchTerm', text)}
 | 
			
		||||
    clearSearch={() => console.log('clearSearch')}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Starting with a search term
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MainHeader
 | 
			
		||||
    name="John Smith"
 | 
			
		||||
    color="purple"
 | 
			
		||||
    searchTerm="Hewwo?"
 | 
			
		||||
    search={text => console.log('search', text)}
 | 
			
		||||
    updateSearchTerm={text => console.log('updateSearchTerm', text)}
 | 
			
		||||
    clearSearch={() => console.log('clearSearch')}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,24 +1,126 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { debounce } from 'lodash';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from './Avatar';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../types/Util';
 | 
			
		||||
import { cleanSearchTerm } from '../util/cleanSearchTerm';
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
export interface Props {
 | 
			
		||||
  searchTerm: string;
 | 
			
		||||
 | 
			
		||||
  // To be used as an ID
 | 
			
		||||
  ourNumber: string;
 | 
			
		||||
  regionCode: string;
 | 
			
		||||
 | 
			
		||||
  // For display
 | 
			
		||||
  phoneNumber: string;
 | 
			
		||||
  isMe?: boolean;
 | 
			
		||||
  isMe: boolean;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  color?: string;
 | 
			
		||||
  color: string;
 | 
			
		||||
  verified: boolean;
 | 
			
		||||
  profileName?: string;
 | 
			
		||||
  avatarPath?: string;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  updateSearchTerm: (searchTerm: string) => void;
 | 
			
		||||
  search: (
 | 
			
		||||
    query: string,
 | 
			
		||||
    options: {
 | 
			
		||||
      regionCode: string;
 | 
			
		||||
      ourNumber: string;
 | 
			
		||||
      noteToSelf: string;
 | 
			
		||||
    }
 | 
			
		||||
  ) => void;
 | 
			
		||||
  clearSearch: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class MainHeader extends React.Component<Props> {
 | 
			
		||||
  private readonly updateSearchBound: (
 | 
			
		||||
    event: React.FormEvent<HTMLInputElement>
 | 
			
		||||
  ) => void;
 | 
			
		||||
  private readonly clearSearchBound: () => void;
 | 
			
		||||
  private readonly handleKeyUpBound: (
 | 
			
		||||
    event: React.KeyboardEvent<HTMLInputElement>
 | 
			
		||||
  ) => void;
 | 
			
		||||
  private readonly setFocusBound: () => void;
 | 
			
		||||
  private readonly inputRef: React.RefObject<HTMLInputElement>;
 | 
			
		||||
  private readonly debouncedSearch: (searchTerm: string) => void;
 | 
			
		||||
 | 
			
		||||
  constructor(props: Props) {
 | 
			
		||||
    super(props);
 | 
			
		||||
 | 
			
		||||
    this.updateSearchBound = this.updateSearch.bind(this);
 | 
			
		||||
    this.clearSearchBound = this.clearSearch.bind(this);
 | 
			
		||||
    this.handleKeyUpBound = this.handleKeyUp.bind(this);
 | 
			
		||||
    this.setFocusBound = this.setFocus.bind(this);
 | 
			
		||||
    this.inputRef = React.createRef();
 | 
			
		||||
 | 
			
		||||
    this.debouncedSearch = debounce(this.search.bind(this), 20);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public search() {
 | 
			
		||||
    const { searchTerm, search, i18n, ourNumber, regionCode } = this.props;
 | 
			
		||||
    if (search) {
 | 
			
		||||
      search(searchTerm, {
 | 
			
		||||
        noteToSelf: i18n('noteToSelf').toLowerCase(),
 | 
			
		||||
        ourNumber,
 | 
			
		||||
        regionCode,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public updateSearch(event: React.FormEvent<HTMLInputElement>) {
 | 
			
		||||
    const { updateSearchTerm, clearSearch } = this.props;
 | 
			
		||||
    const searchTerm = event.currentTarget.value;
 | 
			
		||||
 | 
			
		||||
    if (!searchTerm) {
 | 
			
		||||
      clearSearch();
 | 
			
		||||
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (updateSearchTerm) {
 | 
			
		||||
      updateSearchTerm(searchTerm);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (searchTerm.length < 2) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const cleanedTerm = cleanSearchTerm(searchTerm);
 | 
			
		||||
    if (!cleanedTerm) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.debouncedSearch(cleanedTerm);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public clearSearch() {
 | 
			
		||||
    const { clearSearch } = this.props;
 | 
			
		||||
 | 
			
		||||
    clearSearch();
 | 
			
		||||
    this.setFocus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
 | 
			
		||||
    const { clearSearch } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (event.key === 'Escape') {
 | 
			
		||||
      clearSearch();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public setFocus() {
 | 
			
		||||
    if (this.inputRef.current) {
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      this.inputRef.current.focus();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    const {
 | 
			
		||||
      searchTerm,
 | 
			
		||||
      avatarPath,
 | 
			
		||||
      i18n,
 | 
			
		||||
      color,
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +141,30 @@ export class MainHeader extends React.Component<Props> {
 | 
			
		|||
          profileName={profileName}
 | 
			
		||||
          size={28}
 | 
			
		||||
        />
 | 
			
		||||
        <div className="module-main-header__app-name">Signal</div>
 | 
			
		||||
        <div className="module-main-header__search">
 | 
			
		||||
          <div
 | 
			
		||||
            role="button"
 | 
			
		||||
            className="module-main-header__search__icon"
 | 
			
		||||
            onClick={this.setFocusBound}
 | 
			
		||||
          />
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            ref={this.inputRef}
 | 
			
		||||
            className="module-main-header__search__input"
 | 
			
		||||
            placeholder={i18n('search')}
 | 
			
		||||
            dir="auto"
 | 
			
		||||
            onKeyUp={this.handleKeyUpBound}
 | 
			
		||||
            value={searchTerm}
 | 
			
		||||
            onChange={this.updateSearchBound}
 | 
			
		||||
          />
 | 
			
		||||
          {searchTerm ? (
 | 
			
		||||
            <div
 | 
			
		||||
              role="button"
 | 
			
		||||
              className="module-main-header__search__cancel-icon"
 | 
			
		||||
              onClick={this.clearSearchBound}
 | 
			
		||||
            />
 | 
			
		||||
          ) : null}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										41
									
								
								ts/components/MessageBodyHighlight.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								ts/components/MessageBodyHighlight.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
Basic replacement
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MessageBodyHighlight
 | 
			
		||||
  text="This is before <<left>>Inside<<right>> This is after."
 | 
			
		||||
  i18n={util.i18n}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
With no replacement
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MessageBodyHighlight
 | 
			
		||||
  text="All\nplain\ntext 🔥 http://somewhere.com"
 | 
			
		||||
  i18n={util.i18n}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
With two replacements
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MessageBodyHighlight
 | 
			
		||||
  text="Begin <<left>>Inside #1<<right>> This is between the two <<left>>Inside #2<<right>> End."
 | 
			
		||||
  i18n={util.i18n}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
With emoji, newlines, and URLs
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MessageBodyHighlight
 | 
			
		||||
  text="http://somewhere.com\n\n🔥 Before -- <<left>>A 🔥 inside<<right>> -- After 🔥"
 | 
			
		||||
  i18n={util.i18n}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
No jumbomoji
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<MessageBodyHighlight text="🔥" i18n={util.i18n} />
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										111
									
								
								ts/components/MessageBodyHighlight.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								ts/components/MessageBodyHighlight.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { MessageBody } from './conversation/MessageBody';
 | 
			
		||||
import { Emojify } from './conversation/Emojify';
 | 
			
		||||
import { AddNewLines } from './conversation/AddNewLines';
 | 
			
		||||
 | 
			
		||||
import { SizeClassType } from '../util/emoji';
 | 
			
		||||
 | 
			
		||||
import { LocalizerType, RenderTextCallbackType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  text: string;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const renderNewLines: RenderTextCallbackType = ({ text, key }) => (
 | 
			
		||||
  <AddNewLines key={key} text={text} />
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const renderEmoji = ({
 | 
			
		||||
  i18n,
 | 
			
		||||
  text,
 | 
			
		||||
  key,
 | 
			
		||||
  sizeClass,
 | 
			
		||||
  renderNonEmoji,
 | 
			
		||||
}: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  text: string;
 | 
			
		||||
  key: number;
 | 
			
		||||
  sizeClass?: SizeClassType;
 | 
			
		||||
  renderNonEmoji: RenderTextCallbackType;
 | 
			
		||||
}) => (
 | 
			
		||||
  <Emojify
 | 
			
		||||
    i18n={i18n}
 | 
			
		||||
    key={key}
 | 
			
		||||
    text={text}
 | 
			
		||||
    sizeClass={sizeClass}
 | 
			
		||||
    renderNonEmoji={renderNonEmoji}
 | 
			
		||||
  />
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export class MessageBodyHighlight extends React.Component<Props> {
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { text, i18n } = this.props;
 | 
			
		||||
    const results: Array<any> = [];
 | 
			
		||||
    const FIND_BEGIN_END = /<<left>>(.+?)<<right>>/g;
 | 
			
		||||
 | 
			
		||||
    let match = FIND_BEGIN_END.exec(text);
 | 
			
		||||
    let last = 0;
 | 
			
		||||
    let count = 1;
 | 
			
		||||
 | 
			
		||||
    if (!match) {
 | 
			
		||||
      return (
 | 
			
		||||
        <MessageBody
 | 
			
		||||
          disableJumbomoji={true}
 | 
			
		||||
          disableLinks={true}
 | 
			
		||||
          text={text}
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const sizeClass = '';
 | 
			
		||||
 | 
			
		||||
    while (match) {
 | 
			
		||||
      if (last < match.index) {
 | 
			
		||||
        const beforeText = text.slice(last, match.index);
 | 
			
		||||
        results.push(
 | 
			
		||||
          renderEmoji({
 | 
			
		||||
            text: beforeText,
 | 
			
		||||
            sizeClass,
 | 
			
		||||
            key: count++,
 | 
			
		||||
            i18n,
 | 
			
		||||
            renderNonEmoji: renderNewLines,
 | 
			
		||||
          })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const [, toHighlight] = match;
 | 
			
		||||
      results.push(
 | 
			
		||||
        <span className="module-message-body__highlight" key={count++}>
 | 
			
		||||
          {renderEmoji({
 | 
			
		||||
            text: toHighlight,
 | 
			
		||||
            sizeClass,
 | 
			
		||||
            key: count++,
 | 
			
		||||
            i18n,
 | 
			
		||||
            renderNonEmoji: renderNewLines,
 | 
			
		||||
          })}
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      last = FIND_BEGIN_END.lastIndex;
 | 
			
		||||
      match = FIND_BEGIN_END.exec(text);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (last < text.length) {
 | 
			
		||||
      results.push(
 | 
			
		||||
        renderEmoji({
 | 
			
		||||
          text: text.slice(last),
 | 
			
		||||
          sizeClass,
 | 
			
		||||
          key: count++,
 | 
			
		||||
          i18n,
 | 
			
		||||
          renderNonEmoji: renderNewLines,
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return results;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										191
									
								
								ts/components/MessageSearchResult.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								ts/components/MessageSearchResult.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,191 @@
 | 
			
		|||
#### With name and profile
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      name: 'Someone 🔥 Somewhere',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="What's <<left>>going<<right>> on?"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 24 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Selected
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      name: 'Someone 🔥 Somewhere',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    isSelected={true}
 | 
			
		||||
    snippet="What's <<left>>going<<right>> on?"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 4 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### From you
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      name: 'Mr. Smith',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="What's <<left>>going<<right>> on?"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 3 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      name: 'Everyone 🔥',
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="How is everyone? <<left>>Going<<right>> well?"
 | 
			
		||||
    id="messageId2"
 | 
			
		||||
    conversationId="conversationId2"
 | 
			
		||||
    receivedAt={Date.now() - 27 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### From you and to you
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="Tuesday: Ate two <<left>>apple<<right>>s"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 3 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Profile, with name, no avatar
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      name: 'Mr. Fire🔥',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      color: 'green',
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="<<left>>Just<<right>> a second"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 7 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With Group
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      name: 'Jon ❄️',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      color: 'green',
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      name: 'My Crew',
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="I'm pretty <<left>>excited<<right>>!"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 30 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### Longer search results
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      name: 'Penny J',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      color: 'purple',
 | 
			
		||||
      avatarPath: util.pngImagePath,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      name: 'Softball 🥎',
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="This is a really <<left>>detail<<right>>ed long line which will wrap and only be cut off after it gets to three lines. So maybe this will make it in as well?"
 | 
			
		||||
    id="messageId1"
 | 
			
		||||
    conversationId="conversationId1"
 | 
			
		||||
    receivedAt={Date.now() - 17 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
  <MessageSearchResult
 | 
			
		||||
    from={{
 | 
			
		||||
      name: 'Tim Smith',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      color: 'red',
 | 
			
		||||
      avatarPath: util.pngImagePath,
 | 
			
		||||
    }}
 | 
			
		||||
    to={{
 | 
			
		||||
      name: 'Maple 🍁',
 | 
			
		||||
    }}
 | 
			
		||||
    snippet="Okay, here are the <<left>>detail<<right>>s:\n\n1355 Ridge Way\nCode: 234\n\nI'm excited!"
 | 
			
		||||
    id="messageId2"
 | 
			
		||||
    conversationId="conversationId2"
 | 
			
		||||
    receivedAt={Date.now() - 10 * 60 * 60 * 1000}
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										166
									
								
								ts/components/MessageSearchResult.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								ts/components/MessageSearchResult.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,166 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from './Avatar';
 | 
			
		||||
import { MessageBodyHighlight } from './MessageBodyHighlight';
 | 
			
		||||
import { Timestamp } from './conversation/Timestamp';
 | 
			
		||||
import { ContactName } from './conversation/ContactName';
 | 
			
		||||
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
export type PropsData = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  conversationId: string;
 | 
			
		||||
  receivedAt: number;
 | 
			
		||||
 | 
			
		||||
  snippet: string;
 | 
			
		||||
 | 
			
		||||
  from: {
 | 
			
		||||
    phoneNumber: string;
 | 
			
		||||
    isMe?: boolean;
 | 
			
		||||
    name?: string;
 | 
			
		||||
    color?: string;
 | 
			
		||||
    profileName?: string;
 | 
			
		||||
    avatarPath?: string;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  to: {
 | 
			
		||||
    groupName?: string;
 | 
			
		||||
    phoneNumber: string;
 | 
			
		||||
    isMe?: boolean;
 | 
			
		||||
    name?: string;
 | 
			
		||||
    profileName?: string;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type PropsHousekeeping = {
 | 
			
		||||
  isSelected?: boolean;
 | 
			
		||||
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onClick: (conversationId: string, messageId?: string) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type Props = PropsData & PropsHousekeeping;
 | 
			
		||||
 | 
			
		||||
export class MessageSearchResult extends React.PureComponent<Props> {
 | 
			
		||||
  public renderFromName() {
 | 
			
		||||
    const { from, i18n, to } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (from.isMe && to.isMe) {
 | 
			
		||||
      return (
 | 
			
		||||
        <span className="module-message-search-result__header__name">
 | 
			
		||||
          {i18n('noteToSelf')}
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    if (from.isMe) {
 | 
			
		||||
      return (
 | 
			
		||||
        <span className="module-message-search-result__header__name">
 | 
			
		||||
          {i18n('you')}
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ContactName
 | 
			
		||||
        phoneNumber={from.phoneNumber}
 | 
			
		||||
        name={from.name}
 | 
			
		||||
        profileName={from.profileName}
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        module="module-message-search-result__header__name"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public renderFrom() {
 | 
			
		||||
    const { i18n, to } = this.props;
 | 
			
		||||
    const fromName = this.renderFromName();
 | 
			
		||||
 | 
			
		||||
    if (!to.isMe) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="module-message-search-result__header__from">
 | 
			
		||||
          {fromName} {i18n('to')}{' '}
 | 
			
		||||
          <span className="module-mesages-search-result__header__group">
 | 
			
		||||
            <ContactName
 | 
			
		||||
              phoneNumber={to.phoneNumber}
 | 
			
		||||
              name={to.name}
 | 
			
		||||
              profileName={to.profileName}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
            />
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-message-search-result__header__from">
 | 
			
		||||
        {fromName}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public renderAvatar() {
 | 
			
		||||
    const { from, i18n, to } = this.props;
 | 
			
		||||
    const isNoteToSelf = from.isMe && to.isMe;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Avatar
 | 
			
		||||
        avatarPath={from.avatarPath}
 | 
			
		||||
        color={from.color}
 | 
			
		||||
        conversationType="direct"
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        name={name}
 | 
			
		||||
        noteToSelf={isNoteToSelf}
 | 
			
		||||
        phoneNumber={from.phoneNumber}
 | 
			
		||||
        profileName={from.profileName}
 | 
			
		||||
        size={48}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    const {
 | 
			
		||||
      from,
 | 
			
		||||
      i18n,
 | 
			
		||||
      id,
 | 
			
		||||
      isSelected,
 | 
			
		||||
      conversationId,
 | 
			
		||||
      onClick,
 | 
			
		||||
      receivedAt,
 | 
			
		||||
      snippet,
 | 
			
		||||
      to,
 | 
			
		||||
    } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (!from || !to) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        role="button"
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          if (onClick) {
 | 
			
		||||
            onClick(conversationId, id);
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        className={classNames(
 | 
			
		||||
          'module-message-search-result',
 | 
			
		||||
          isSelected ? 'module-message-search-result--is-selected' : null
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        {this.renderAvatar()}
 | 
			
		||||
        <div className="module-message-search-result__text">
 | 
			
		||||
          <div className="module-message-search-result__header">
 | 
			
		||||
            {this.renderFrom()}
 | 
			
		||||
            <div className="module-message-search-result__header__timestamp">
 | 
			
		||||
              <Timestamp timestamp={receivedAt} i18n={i18n} />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="module-message-search-result__body">
 | 
			
		||||
            <MessageBodyHighlight text={snippet} i18n={i18n} />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										259
									
								
								ts/components/SearchResults.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										259
									
								
								ts/components/SearchResults.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,259 @@
 | 
			
		|||
#### With all result types
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
window.searchResults = {};
 | 
			
		||||
window.searchResults.conversations = [
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Everyone 🌆',
 | 
			
		||||
    conversationType: 'group',
 | 
			
		||||
    phoneNumber: '(202) 555-0011',
 | 
			
		||||
    avatarPath: util.landscapeGreenObjectUrl,
 | 
			
		||||
    lastUpdated: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    lastMessage: {
 | 
			
		||||
      text: 'The rabbit hopped silently in the night.',
 | 
			
		||||
      status: 'sent',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Everyone Else 🔥',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0012',
 | 
			
		||||
    avatarPath: util.landscapePurpleObjectUrl,
 | 
			
		||||
    lastUpdated: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    lastMessage: {
 | 
			
		||||
      text: "What's going on?",
 | 
			
		||||
      status: 'sent',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
window.searchResults.contacts = [
 | 
			
		||||
  {
 | 
			
		||||
    name: 'The one Everyone',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0013',
 | 
			
		||||
    avatarPath: util.gifObjectUrl,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'No likey everyone',
 | 
			
		||||
    conversationType: 'direct',
 | 
			
		||||
    phoneNumber: '(202) 555-0014',
 | 
			
		||||
    color: 'red',
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
window.searchResults.messages = [
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      name: 'Mr. Fire 🔥',
 | 
			
		||||
      phoneNumber: '(202) 555-0015',
 | 
			
		||||
    },
 | 
			
		||||
    id: '1-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: '(202) 555-0015',
 | 
			
		||||
    receivedAt: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    snippet: '<<left>>Everyone<<right>>! Get in!',
 | 
			
		||||
    onClick: () => console.log('onClick'),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      name: 'Jon ❄️',
 | 
			
		||||
      phoneNumber: '(202) 555-0016',
 | 
			
		||||
      color: 'green',
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    },
 | 
			
		||||
    id: '2-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: '(202) 555-0016',
 | 
			
		||||
    snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
 | 
			
		||||
    receivedAt: Date.now() - 20 * 60 * 1000,
 | 
			
		||||
    onClick: () => console.log('onClick'),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      name: 'Someone',
 | 
			
		||||
      phoneNumber: '(202) 555-0011',
 | 
			
		||||
      color: 'green',
 | 
			
		||||
      avatarPath: util.pngObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      name: "Y'all 🌆",
 | 
			
		||||
    },
 | 
			
		||||
    id: '3-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: 'EveryoneGroupID',
 | 
			
		||||
    snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
 | 
			
		||||
    receivedAt: Date.now() - 24 * 60 * 1000,
 | 
			
		||||
    onClick: () => console.log('onClick'),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    from: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
      avatarPath: util.gifObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      name: "Y'all 🌆",
 | 
			
		||||
    },
 | 
			
		||||
    id: '4-guid-guid-guid-guid-guid',
 | 
			
		||||
    conversationId: 'EveryoneGroupID',
 | 
			
		||||
    snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
 | 
			
		||||
    receivedAt: Date.now() - 24 * 60 * 1000,
 | 
			
		||||
    onClick: () => console.log('onClick'),
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={window.searchResults.conversations}
 | 
			
		||||
    contacts={window.searchResults.contacts}
 | 
			
		||||
    messages={window.searchResults.messages}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
    onClickMessage={id => console.log('onClickMessage', id)}
 | 
			
		||||
    onClickConversation={id => console.log('onClickConversation', id)}
 | 
			
		||||
    onStartNewConversation={() => console.log('onStartNewConversation')}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>;
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With 'start new conversation'
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={window.searchResults.conversations}
 | 
			
		||||
    contacts={window.searchResults.contacts}
 | 
			
		||||
    messages={window.searchResults.messages}
 | 
			
		||||
    showStartNewConversation={true}
 | 
			
		||||
    searchTerm="(555) 100-2000"
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
    onClickMessage={id => console.log('onClickMessage', id)}
 | 
			
		||||
    onClickConversation={id => console.log('onClickConversation', id)}
 | 
			
		||||
    onStartNewConversation={() => console.log('onStartNewConversation')}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With no conversations
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={null}
 | 
			
		||||
    contacts={window.searchResults.contacts}
 | 
			
		||||
    messages={window.searchResults.messages}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
    onClickMessage={id => console.log('onClickMessage', id)}
 | 
			
		||||
    onClickConversation={id => console.log('onClickConversation', id)}
 | 
			
		||||
    onStartNewConversation={() => console.log('onStartNewConversation')}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With no contacts
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={window.searchResults.conversations}
 | 
			
		||||
    contacts={null}
 | 
			
		||||
    messages={window.searchResults.messages}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
    onClickMessage={id => console.log('onClickMessage', id)}
 | 
			
		||||
    onClickConversation={id => console.log('onClickConversation', id)}
 | 
			
		||||
    onStartNewConversation={() => console.log('onStartNewConversation')}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With no messages
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={window.searchResults.conversations}
 | 
			
		||||
    contacts={window.searchResults.contacts}
 | 
			
		||||
    messages={null}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With no results at all
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={null}
 | 
			
		||||
    contacts={null}
 | 
			
		||||
    messages={null}
 | 
			
		||||
    searchTerm="dinner plans"
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With a lot of results
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
const messages = [];
 | 
			
		||||
for (let i = 0; i < 100; i += 1) {
 | 
			
		||||
  messages.push({
 | 
			
		||||
    from: {
 | 
			
		||||
      name: 'Mr. Fire 🔥',
 | 
			
		||||
      phoneNumber: '(202) 555-0015',
 | 
			
		||||
      avatarPath: util.landscapeGreenObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    },
 | 
			
		||||
    id: `${i}-guid-guid-guid-guid-guid`,
 | 
			
		||||
    conversationId: '(202) 555-0015',
 | 
			
		||||
    receivedAt: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    snippet: `${i} <<left>>Everyone<<right>>! Get in!`,
 | 
			
		||||
    onClick: data => console.log('onClick', data),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
<util.LeftPaneContext style={{ height: '500px' }} theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    conversations={null}
 | 
			
		||||
    contacts={null}
 | 
			
		||||
    messages={messages}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>;
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With just messages and no header
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
const messages = [];
 | 
			
		||||
for (let i = 0; i < 10; i += 1) {
 | 
			
		||||
  messages.push({
 | 
			
		||||
    from: {
 | 
			
		||||
      name: 'Mr. Fire 🔥',
 | 
			
		||||
      phoneNumber: '(202) 555-0015',
 | 
			
		||||
      avatarPath: util.landscapeGreenObjectUrl,
 | 
			
		||||
    },
 | 
			
		||||
    to: {
 | 
			
		||||
      isMe: true,
 | 
			
		||||
    },
 | 
			
		||||
    id: `${i}-guid-guid-guid-guid-guid`,
 | 
			
		||||
    conversationId: '(202) 555-0015',
 | 
			
		||||
    receivedAt: Date.now() - 5 * 60 * 1000,
 | 
			
		||||
    snippet: `${i} <<left>>Everyone<<right>>! Get in!`,
 | 
			
		||||
    onClick: data => console.log('onClick', data),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
<util.LeftPaneContext style={{ height: '500px' }} theme={util.theme}>
 | 
			
		||||
  <SearchResults
 | 
			
		||||
    hideMessagesHeader={true}
 | 
			
		||||
    messages={messages}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>;
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										118
									
								
								ts/components/SearchResults.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								ts/components/SearchResults.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  ConversationListItem,
 | 
			
		||||
  PropsData as ConversationListItemPropsType,
 | 
			
		||||
} from './ConversationListItem';
 | 
			
		||||
import {
 | 
			
		||||
  MessageSearchResult,
 | 
			
		||||
  PropsData as MessageSearchResultPropsType,
 | 
			
		||||
} from './MessageSearchResult';
 | 
			
		||||
import { StartNewConversation } from './StartNewConversation';
 | 
			
		||||
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
export type PropsData = {
 | 
			
		||||
  contacts: Array<ConversationListItemPropsType>;
 | 
			
		||||
  conversations: Array<ConversationListItemPropsType>;
 | 
			
		||||
  hideMessagesHeader: boolean;
 | 
			
		||||
  messages: Array<MessageSearchResultPropsType>;
 | 
			
		||||
  searchTerm: string;
 | 
			
		||||
  showStartNewConversation: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type PropsHousekeeping = {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  openConversation: (id: string, messageId?: string) => void;
 | 
			
		||||
  startNewConversation: (id: string) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type Props = PropsData & PropsHousekeeping;
 | 
			
		||||
 | 
			
		||||
export class SearchResults extends React.Component<Props> {
 | 
			
		||||
  public render() {
 | 
			
		||||
    const {
 | 
			
		||||
      conversations,
 | 
			
		||||
      contacts,
 | 
			
		||||
      hideMessagesHeader,
 | 
			
		||||
      i18n,
 | 
			
		||||
      messages,
 | 
			
		||||
      openConversation,
 | 
			
		||||
      startNewConversation,
 | 
			
		||||
      searchTerm,
 | 
			
		||||
      showStartNewConversation,
 | 
			
		||||
    } = this.props;
 | 
			
		||||
 | 
			
		||||
    const haveConversations = conversations && conversations.length;
 | 
			
		||||
    const haveContacts = contacts && contacts.length;
 | 
			
		||||
    const haveMessages = messages && messages.length;
 | 
			
		||||
    const noResults =
 | 
			
		||||
      !showStartNewConversation &&
 | 
			
		||||
      !haveConversations &&
 | 
			
		||||
      !haveContacts &&
 | 
			
		||||
      !haveMessages;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-search-results">
 | 
			
		||||
        {noResults ? (
 | 
			
		||||
          <div className="module-search-results__no-results">
 | 
			
		||||
            {i18n('noSearchResults', [searchTerm])}
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : null}
 | 
			
		||||
        {showStartNewConversation ? (
 | 
			
		||||
          <StartNewConversation
 | 
			
		||||
            phoneNumber={searchTerm}
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            onClick={startNewConversation}
 | 
			
		||||
          />
 | 
			
		||||
        ) : null}
 | 
			
		||||
        {haveConversations ? (
 | 
			
		||||
          <div className="module-search-results__conversations">
 | 
			
		||||
            <div className="module-search-results__conversations-header">
 | 
			
		||||
              {i18n('conversationsHeader')}
 | 
			
		||||
            </div>
 | 
			
		||||
            {conversations.map(conversation => (
 | 
			
		||||
              <ConversationListItem
 | 
			
		||||
                key={conversation.phoneNumber}
 | 
			
		||||
                {...conversation}
 | 
			
		||||
                onClick={openConversation}
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : null}
 | 
			
		||||
        {haveContacts ? (
 | 
			
		||||
          <div className="module-search-results__contacts">
 | 
			
		||||
            <div className="module-search-results__contacts-header">
 | 
			
		||||
              {i18n('contactsHeader')}
 | 
			
		||||
            </div>
 | 
			
		||||
            {contacts.map(contact => (
 | 
			
		||||
              <ConversationListItem
 | 
			
		||||
                key={contact.phoneNumber}
 | 
			
		||||
                {...contact}
 | 
			
		||||
                onClick={openConversation}
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : null}
 | 
			
		||||
        {haveMessages ? (
 | 
			
		||||
          <div className="module-search-results__messages">
 | 
			
		||||
            {hideMessagesHeader ? null : (
 | 
			
		||||
              <div className="module-search-results__messages-header">
 | 
			
		||||
                {i18n('messagesHeader')}
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
            {messages.map(message => (
 | 
			
		||||
              <MessageSearchResult
 | 
			
		||||
                key={message.id}
 | 
			
		||||
                {...message}
 | 
			
		||||
                onClick={openConversation}
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : null}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								ts/components/StartNewConversation.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								ts/components/StartNewConversation.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
#### With full phone number
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <StartNewConversation
 | 
			
		||||
    phoneNumber="(202) 555-0011"
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### With partial phone number
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<util.LeftPaneContext theme={util.theme}>
 | 
			
		||||
  <StartNewConversation
 | 
			
		||||
    phoneNumber="202"
 | 
			
		||||
    onClick={result => console.log('onClick', result)}
 | 
			
		||||
    i18n={util.i18n}
 | 
			
		||||
  />
 | 
			
		||||
</util.LeftPaneContext>
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										43
									
								
								ts/components/StartNewConversation.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								ts/components/StartNewConversation.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from './Avatar';
 | 
			
		||||
 | 
			
		||||
import { LocalizerType } from '../types/Util';
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  phoneNumber: string;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onClick: (id: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class StartNewConversation extends React.PureComponent<Props> {
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { phoneNumber, i18n, onClick } = this.props;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        role="button"
 | 
			
		||||
        className="module-start-new-conversation"
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          onClick(phoneNumber);
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <Avatar
 | 
			
		||||
          color="grey"
 | 
			
		||||
          conversationType="direct"
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
          phoneNumber={phoneNumber}
 | 
			
		||||
          size={48}
 | 
			
		||||
        />
 | 
			
		||||
        <div className="module-start-new-conversation__content">
 | 
			
		||||
          <div className="module-start-new-conversation__number">
 | 
			
		||||
            {phoneNumber}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="module-start-new-conversation__text">
 | 
			
		||||
            {i18n('startConversation')}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,11 +1,11 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { RenderTextCallback } from '../../types/Util';
 | 
			
		||||
import { RenderTextCallbackType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  text: string;
 | 
			
		||||
  /** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
 | 
			
		||||
  renderNonNewLine?: RenderTextCallback;
 | 
			
		||||
  renderNonNewLine?: RenderTextCallbackType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AddNewLines extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,16 +4,20 @@ import {
 | 
			
		|||
  isImageTypeSupported,
 | 
			
		||||
  isVideoTypeSupported,
 | 
			
		||||
} from '../../util/GoogleChrome';
 | 
			
		||||
import { AttachmentType } from './types';
 | 
			
		||||
import { Image } from './Image';
 | 
			
		||||
import { areAllAttachmentsVisual } from './ImageGrid';
 | 
			
		||||
import { StagedGenericAttachment } from './StagedGenericAttachment';
 | 
			
		||||
import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
import {
 | 
			
		||||
  areAllAttachmentsVisual,
 | 
			
		||||
  AttachmentType,
 | 
			
		||||
  getUrl,
 | 
			
		||||
  isVideoAttachment,
 | 
			
		||||
} from '../../types/Attachment';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  attachments: Array<AttachmentType>;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  // onError: () => void;
 | 
			
		||||
  onClickAttachment: (attachment: AttachmentType) => void;
 | 
			
		||||
  onCloseAttachment: (attachment: AttachmentType) => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -60,9 +64,14 @@ export class AttachmentList extends React.Component<Props> {
 | 
			
		|||
              isImageTypeSupported(contentType) ||
 | 
			
		||||
              isVideoTypeSupported(contentType)
 | 
			
		||||
            ) {
 | 
			
		||||
              const imageKey =
 | 
			
		||||
                getUrl(attachment) || attachment.fileName || index;
 | 
			
		||||
              const clickCallback =
 | 
			
		||||
                attachments.length > 1 ? onClickAttachment : undefined;
 | 
			
		||||
 | 
			
		||||
              return (
 | 
			
		||||
                <Image
 | 
			
		||||
                  key={getUrl(attachment) || attachment.fileName || index}
 | 
			
		||||
                  key={imageKey}
 | 
			
		||||
                  alt={i18n('stagedImageAttachment', [
 | 
			
		||||
                    getUrl(attachment) || attachment.fileName,
 | 
			
		||||
                  ])}
 | 
			
		||||
| 
						 | 
				
			
			@ -74,17 +83,18 @@ export class AttachmentList extends React.Component<Props> {
 | 
			
		|||
                  width={IMAGE_WIDTH}
 | 
			
		||||
                  url={getUrl(attachment)}
 | 
			
		||||
                  closeButton={true}
 | 
			
		||||
                  onClick={
 | 
			
		||||
                    attachments.length > 1 ? onClickAttachment : undefined
 | 
			
		||||
                  }
 | 
			
		||||
                  onClick={clickCallback}
 | 
			
		||||
                  onClickClose={onCloseAttachment}
 | 
			
		||||
                />
 | 
			
		||||
              );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const genericKey =
 | 
			
		||||
              getUrl(attachment) || attachment.fileName || index;
 | 
			
		||||
 | 
			
		||||
            return (
 | 
			
		||||
              <StagedGenericAttachment
 | 
			
		||||
                key={getUrl(attachment) || attachment.fileName || index}
 | 
			
		||||
                key={genericKey}
 | 
			
		||||
                attachment={attachment}
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
                onClose={onCloseAttachment}
 | 
			
		||||
| 
						 | 
				
			
			@ -99,19 +109,3 @@ export class AttachmentList extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isVideoAttachment(attachment?: AttachmentType) {
 | 
			
		||||
  return (
 | 
			
		||||
    attachment &&
 | 
			
		||||
    attachment.contentType &&
 | 
			
		||||
    isVideoTypeSupported(attachment.contentType)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getUrl(attachment: AttachmentType) {
 | 
			
		||||
  if (attachment.screenshot) {
 | 
			
		||||
    return attachment.screenshot.url;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return attachment.url;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,18 +14,18 @@ import {
 | 
			
		|||
  renderAvatar,
 | 
			
		||||
  renderContactShorthand,
 | 
			
		||||
  renderName,
 | 
			
		||||
} from './EmbeddedContact';
 | 
			
		||||
} from './_contactUtil';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  contact: Contact;
 | 
			
		||||
  hasSignalAccount: boolean;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onSendMessage: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getLabelForEmail(method: Email, i18n: Localizer): string {
 | 
			
		||||
function getLabelForEmail(method: Email, i18n: LocalizerType): string {
 | 
			
		||||
  switch (method.type) {
 | 
			
		||||
    case ContactType.CUSTOM:
 | 
			
		||||
      return method.label || i18n('email');
 | 
			
		||||
| 
						 | 
				
			
			@ -40,7 +40,7 @@ function getLabelForEmail(method: Email, i18n: Localizer): string {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getLabelForPhone(method: Phone, i18n: Localizer): string {
 | 
			
		||||
function getLabelForPhone(method: Phone, i18n: LocalizerType): string {
 | 
			
		||||
  switch (method.type) {
 | 
			
		||||
    case ContactType.CUSTOM:
 | 
			
		||||
      return method.label || i18n('phone');
 | 
			
		||||
| 
						 | 
				
			
			@ -55,7 +55,10 @@ function getLabelForPhone(method: Phone, i18n: Localizer): string {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getLabelForAddress(address: PostalAddress, i18n: Localizer): string {
 | 
			
		||||
function getLabelForAddress(
 | 
			
		||||
  address: PostalAddress,
 | 
			
		||||
  i18n: LocalizerType
 | 
			
		||||
): string {
 | 
			
		||||
  switch (address.type) {
 | 
			
		||||
    case AddressType.CUSTOM:
 | 
			
		||||
      return address.label || i18n('address');
 | 
			
		||||
| 
						 | 
				
			
			@ -104,7 +107,7 @@ export class ContactDetail extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public renderEmail(items: Array<Email> | undefined, i18n: Localizer) {
 | 
			
		||||
  public renderEmail(items: Array<Email> | undefined, i18n: LocalizerType) {
 | 
			
		||||
    if (!items || items.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -124,7 +127,7 @@ export class ContactDetail extends React.Component<Props> {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public renderPhone(items: Array<Phone> | undefined, i18n: Localizer) {
 | 
			
		||||
  public renderPhone(items: Array<Phone> | undefined, i18n: LocalizerType) {
 | 
			
		||||
    if (!items || items.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -152,7 +155,7 @@ export class ContactDetail extends React.Component<Props> {
 | 
			
		|||
    return <div>{value}</div>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public renderPOBox(poBox: string | undefined, i18n: Localizer) {
 | 
			
		||||
  public renderPOBox(poBox: string | undefined, i18n: LocalizerType) {
 | 
			
		||||
    if (!poBox) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -178,7 +181,7 @@ export class ContactDetail extends React.Component<Props> {
 | 
			
		|||
 | 
			
		||||
  public renderAddresses(
 | 
			
		||||
    addresses: Array<PostalAddress> | undefined,
 | 
			
		||||
    i18n: Localizer
 | 
			
		||||
    i18n: LocalizerType
 | 
			
		||||
  ) {
 | 
			
		||||
    if (!addresses || addresses.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,13 +2,13 @@ import React from 'react';
 | 
			
		|||
 | 
			
		||||
import { Emojify } from './Emojify';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  phoneNumber: string;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  profileName?: string;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  module?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import React from 'react';
 | 
			
		|||
 | 
			
		||||
import { Emojify } from './Emojify';
 | 
			
		||||
import { Avatar } from '../Avatar';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
import {
 | 
			
		||||
  ContextMenu,
 | 
			
		||||
  ContextMenuTrigger,
 | 
			
		||||
| 
						 | 
				
			
			@ -15,12 +15,8 @@ interface TimerOption {
 | 
			
		|||
  value: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Trigger {
 | 
			
		||||
  handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  isVerified: boolean;
 | 
			
		||||
  name?: string;
 | 
			
		||||
  id: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -46,24 +42,19 @@ interface Props {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export class ConversationHeader extends React.Component<Props> {
 | 
			
		||||
  public captureMenuTriggerBound: (trigger: any) => void;
 | 
			
		||||
  public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
 | 
			
		||||
  public menuTriggerRef: Trigger | null;
 | 
			
		||||
  public menuTriggerRef: React.RefObject<any>;
 | 
			
		||||
 | 
			
		||||
  public constructor(props: Props) {
 | 
			
		||||
    super(props);
 | 
			
		||||
 | 
			
		||||
    this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
 | 
			
		||||
    this.menuTriggerRef = React.createRef();
 | 
			
		||||
    this.showMenuBound = this.showMenu.bind(this);
 | 
			
		||||
    this.menuTriggerRef = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public captureMenuTrigger(triggerRef: Trigger) {
 | 
			
		||||
    this.menuTriggerRef = triggerRef;
 | 
			
		||||
  }
 | 
			
		||||
  public showMenu(event: React.MouseEvent<HTMLDivElement>) {
 | 
			
		||||
    if (this.menuTriggerRef) {
 | 
			
		||||
      this.menuTriggerRef.handleContextClick(event);
 | 
			
		||||
    if (this.menuTriggerRef.current) {
 | 
			
		||||
      this.menuTriggerRef.current.handleContextClick(event);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -134,12 +125,14 @@ export class ConversationHeader extends React.Component<Props> {
 | 
			
		|||
      profileName,
 | 
			
		||||
    } = this.props;
 | 
			
		||||
 | 
			
		||||
    const conversationType = isGroup ? 'group' : 'direct';
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <span className="module-conversation-header__avatar">
 | 
			
		||||
        <Avatar
 | 
			
		||||
          avatarPath={avatarPath}
 | 
			
		||||
          color={color}
 | 
			
		||||
          conversationType={isGroup ? 'group' : 'direct'}
 | 
			
		||||
          conversationType={conversationType}
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
          noteToSelf={isMe}
 | 
			
		||||
          name={name}
 | 
			
		||||
| 
						 | 
				
			
			@ -176,7 +169,7 @@ export class ConversationHeader extends React.Component<Props> {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
 | 
			
		||||
      <ContextMenuTrigger id={triggerId} ref={this.menuTriggerRef}>
 | 
			
		||||
        <div
 | 
			
		||||
          role="button"
 | 
			
		||||
          onClick={this.showMenuBound}
 | 
			
		||||
| 
						 | 
				
			
			@ -186,7 +179,6 @@ export class ConversationHeader extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* tslint:disable:jsx-no-lambda react-this-binding-issue */
 | 
			
		||||
  public renderMenu(triggerId: string) {
 | 
			
		||||
    const {
 | 
			
		||||
      i18n,
 | 
			
		||||
| 
						 | 
				
			
			@ -235,10 +227,10 @@ export class ConversationHeader extends React.Component<Props> {
 | 
			
		|||
      </ContextMenu>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  /* tslint:enable */
 | 
			
		||||
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { id } = this.props;
 | 
			
		||||
    const triggerId = `conversation-${id}`;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-conversation-header">
 | 
			
		||||
| 
						 | 
				
			
			@ -250,8 +242,8 @@ export class ConversationHeader extends React.Component<Props> {
 | 
			
		|||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        {this.renderExpirationLength()}
 | 
			
		||||
        {this.renderGear(id)}
 | 
			
		||||
        {this.renderMenu(id)}
 | 
			
		||||
        {this.renderGear(triggerId)}
 | 
			
		||||
        {this.renderMenu(triggerId)}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,16 +1,19 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from '../Avatar';
 | 
			
		||||
import { Spinner } from '../Spinner';
 | 
			
		||||
import { Contact, getName } from '../../types/Contact';
 | 
			
		||||
import { Contact } from '../../types/Contact';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
import {
 | 
			
		||||
  renderAvatar,
 | 
			
		||||
  renderContactShorthand,
 | 
			
		||||
  renderName,
 | 
			
		||||
} from './_contactUtil';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  contact: Contact;
 | 
			
		||||
  hasSignalAccount: boolean;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  isIncoming: boolean;
 | 
			
		||||
  withContentAbove: boolean;
 | 
			
		||||
  withContentBelow: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -53,88 +56,3 @@ export class EmbeddedContact extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Note: putting these below the main component so style guide picks up EmbeddedContact
 | 
			
		||||
 | 
			
		||||
export function renderAvatar({
 | 
			
		||||
  contact,
 | 
			
		||||
  i18n,
 | 
			
		||||
  size,
 | 
			
		||||
  direction,
 | 
			
		||||
}: {
 | 
			
		||||
  contact: Contact;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  size: number;
 | 
			
		||||
  direction?: string;
 | 
			
		||||
}) {
 | 
			
		||||
  const { avatar } = contact;
 | 
			
		||||
 | 
			
		||||
  const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
 | 
			
		||||
  const pending = avatar && avatar.avatar && avatar.avatar.pending;
 | 
			
		||||
  const name = getName(contact) || '';
 | 
			
		||||
 | 
			
		||||
  if (pending) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-embedded-contact__spinner-container">
 | 
			
		||||
        <Spinner small={size < 50} direction={direction} />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Avatar
 | 
			
		||||
      avatarPath={avatarPath}
 | 
			
		||||
      color="grey"
 | 
			
		||||
      conversationType="direct"
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      name={name}
 | 
			
		||||
      size={size}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
  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={classNames(
 | 
			
		||||
        `module-${module}__contact-method`,
 | 
			
		||||
        isIncoming ? `module-${module}__contact-method--incoming` : null
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      {firstNumber || firstEmail}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ import {
 | 
			
		|||
  SizeClassType,
 | 
			
		||||
} from '../../util/emoji';
 | 
			
		||||
 | 
			
		||||
import { Localizer, RenderTextCallback } from '../../types/Util';
 | 
			
		||||
import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
// Some of this logic taken from emoji-js/replacement
 | 
			
		||||
function getImageTag({
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ function getImageTag({
 | 
			
		|||
  match: any;
 | 
			
		||||
  sizeClass?: SizeClassType;
 | 
			
		||||
  key: string | number;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}) {
 | 
			
		||||
  const result = getReplacementData(match[0], match[1], match[2]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -54,8 +54,8 @@ interface Props {
 | 
			
		|||
  /** A class name to be added to the generated emoji images */
 | 
			
		||||
  sizeClass?: SizeClassType;
 | 
			
		||||
  /** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
 | 
			
		||||
  renderNonEmoji?: RenderTextCallback;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  renderNonEmoji?: RenderTextCallbackType;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Emojify extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { padStart } from 'lodash';
 | 
			
		||||
import { getIncrement, getTimerBucket } from '../../util/timer';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  withImageNoCaption: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -62,25 +62,3 @@ export class ExpireTimer extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getIncrement(length: number): number {
 | 
			
		||||
  if (length < 0) {
 | 
			
		||||
    return 1000;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return Math.ceil(length / 12);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getTimerBucket(expiration: number, length: number): string {
 | 
			
		||||
  const delta = expiration - Date.now();
 | 
			
		||||
  if (delta < 0) {
 | 
			
		||||
    return '00';
 | 
			
		||||
  }
 | 
			
		||||
  if (delta > length) {
 | 
			
		||||
    return '60';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const bucket = Math.round(delta / length * 12);
 | 
			
		||||
 | 
			
		||||
  return padStart(String(bucket * 5), 2, '0');
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,7 @@ import { compact, flatten } from 'lodash';
 | 
			
		|||
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
import { Intl } from '../Intl';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
import { missingCaseError } from '../../util/missingCaseError';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ interface Change {
 | 
			
		|||
 | 
			
		||||
interface Props {
 | 
			
		||||
  changes: Array<Change>;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class GroupNotification extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			@ -61,15 +61,10 @@ export class GroupNotification extends React.Component<Props> {
 | 
			
		|||
          throw new Error('Group update is missing contacts');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <Intl
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            id={
 | 
			
		||||
              contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'
 | 
			
		||||
            }
 | 
			
		||||
            components={[people]}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
        const joinKey =
 | 
			
		||||
          contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup';
 | 
			
		||||
 | 
			
		||||
        return <Intl i18n={i18n} id={joinKey} components={[people]} />;
 | 
			
		||||
      case 'remove':
 | 
			
		||||
        if (isMe) {
 | 
			
		||||
          return i18n('youLeftTheGroup');
 | 
			
		||||
| 
						 | 
				
			
			@ -79,13 +74,10 @@ export class GroupNotification extends React.Component<Props> {
 | 
			
		|||
          throw new Error('Group update is missing contacts');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
          <Intl
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            id={contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'}
 | 
			
		||||
            components={[people]}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
        const leftKey =
 | 
			
		||||
          contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
 | 
			
		||||
 | 
			
		||||
        return <Intl i18n={i18n} id={leftKey} components={[people]} />;
 | 
			
		||||
      case 'general':
 | 
			
		||||
        return i18n('updatedTheGroup');
 | 
			
		||||
      default:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,8 +2,8 @@ import React from 'react';
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { Spinner } from '../Spinner';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { AttachmentType } from './types';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
import { AttachmentType } from '../../types/Attachment';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  alt: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +28,7 @@ interface Props {
 | 
			
		|||
  playIconOverlay?: boolean;
 | 
			
		||||
  softCorners?: boolean;
 | 
			
		||||
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onClick?: (attachment: AttachmentType) => void;
 | 
			
		||||
  onClickClose?: (attachment: AttachmentType) => void;
 | 
			
		||||
  onError?: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -62,10 +62,11 @@ export class Image extends React.Component<Props> {
 | 
			
		|||
 | 
			
		||||
    const { caption, pending } = attachment || { caption: null, pending: true };
 | 
			
		||||
    const canClick = onClick && !pending;
 | 
			
		||||
    const role = canClick ? 'button' : undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        role={canClick ? 'button' : undefined}
 | 
			
		||||
        role={role}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          if (canClick && onClick) {
 | 
			
		||||
            onClick(attachment);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,12 +2,18 @@ import React from 'react';
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  isImageTypeSupported,
 | 
			
		||||
  isVideoTypeSupported,
 | 
			
		||||
} from '../../util/GoogleChrome';
 | 
			
		||||
import { AttachmentType } from './types';
 | 
			
		||||
  areAllAttachmentsVisual,
 | 
			
		||||
  AttachmentType,
 | 
			
		||||
  getAlt,
 | 
			
		||||
  getImageDimensions,
 | 
			
		||||
  getThumbnailUrl,
 | 
			
		||||
  getUrl,
 | 
			
		||||
  isVideoAttachment,
 | 
			
		||||
} from '../../types/Attachment';
 | 
			
		||||
 | 
			
		||||
import { Image } from './Image';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  attachments: Array<AttachmentType>;
 | 
			
		||||
| 
						 | 
				
			
			@ -15,17 +21,12 @@ interface Props {
 | 
			
		|||
  withContentBelow?: boolean;
 | 
			
		||||
  bottomOverlay?: boolean;
 | 
			
		||||
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
 | 
			
		||||
  onError: () => void;
 | 
			
		||||
  onClickAttachment?: (attachment: AttachmentType) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MAX_WIDTH = 300;
 | 
			
		||||
const MAX_HEIGHT = MAX_WIDTH * 1.5;
 | 
			
		||||
const MIN_WIDTH = 200;
 | 
			
		||||
const MIN_HEIGHT = 50;
 | 
			
		||||
 | 
			
		||||
export class ImageGrid extends React.Component<Props> {
 | 
			
		||||
  // tslint:disable-next-line max-func-body-length */
 | 
			
		||||
  public render() {
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +47,8 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
    const curveBottomLeft = curveBottom;
 | 
			
		||||
    const curveBottomRight = curveBottom;
 | 
			
		||||
 | 
			
		||||
    const withBottomOverlay = Boolean(bottomOverlay && curveBottom);
 | 
			
		||||
 | 
			
		||||
    if (!attachments || !attachments.length) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -63,7 +66,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
          <Image
 | 
			
		||||
            alt={getAlt(attachments[0], i18n)}
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
            bottomOverlay={withBottomOverlay}
 | 
			
		||||
            curveTopLeft={curveTopLeft}
 | 
			
		||||
            curveTopRight={curveTopRight}
 | 
			
		||||
            curveBottomLeft={curveBottomLeft}
 | 
			
		||||
| 
						 | 
				
			
			@ -87,7 +90,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
            alt={getAlt(attachments[0], i18n)}
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            attachment={attachments[0]}
 | 
			
		||||
            bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
            bottomOverlay={withBottomOverlay}
 | 
			
		||||
            curveTopLeft={curveTopLeft}
 | 
			
		||||
            curveBottomLeft={curveBottomLeft}
 | 
			
		||||
            playIconOverlay={isVideoAttachment(attachments[0])}
 | 
			
		||||
| 
						 | 
				
			
			@ -100,7 +103,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
          <Image
 | 
			
		||||
            alt={getAlt(attachments[1], i18n)}
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
            bottomOverlay={withBottomOverlay}
 | 
			
		||||
            curveTopRight={curveTopRight}
 | 
			
		||||
            curveBottomRight={curveBottomRight}
 | 
			
		||||
            playIconOverlay={isVideoAttachment(attachments[1])}
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +124,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
          <Image
 | 
			
		||||
            alt={getAlt(attachments[0], i18n)}
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
            bottomOverlay={withBottomOverlay}
 | 
			
		||||
            curveTopLeft={curveTopLeft}
 | 
			
		||||
            curveBottomLeft={curveBottomLeft}
 | 
			
		||||
            attachment={attachments[0]}
 | 
			
		||||
| 
						 | 
				
			
			@ -148,7 +151,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
            <Image
 | 
			
		||||
              alt={getAlt(attachments[2], i18n)}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
              bottomOverlay={withBottomOverlay}
 | 
			
		||||
              curveBottomRight={curveBottomRight}
 | 
			
		||||
              height={99}
 | 
			
		||||
              width={99}
 | 
			
		||||
| 
						 | 
				
			
			@ -197,7 +200,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
              <Image
 | 
			
		||||
                alt={getAlt(attachments[2], i18n)}
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
                bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
                bottomOverlay={withBottomOverlay}
 | 
			
		||||
                curveBottomLeft={curveBottomLeft}
 | 
			
		||||
                playIconOverlay={isVideoAttachment(attachments[2])}
 | 
			
		||||
                height={149}
 | 
			
		||||
| 
						 | 
				
			
			@ -210,7 +213,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
              <Image
 | 
			
		||||
                alt={getAlt(attachments[3], i18n)}
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
                bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
                bottomOverlay={withBottomOverlay}
 | 
			
		||||
                curveBottomRight={curveBottomRight}
 | 
			
		||||
                playIconOverlay={isVideoAttachment(attachments[3])}
 | 
			
		||||
                height={149}
 | 
			
		||||
| 
						 | 
				
			
			@ -226,6 +229,11 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const moreMessagesOverlay = attachments.length > 5;
 | 
			
		||||
    const moreMessagesOverlayText = moreMessagesOverlay
 | 
			
		||||
      ? `+${attachments.length - 5}`
 | 
			
		||||
      : undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-image-grid">
 | 
			
		||||
        <div className="module-image-grid__column">
 | 
			
		||||
| 
						 | 
				
			
			@ -259,7 +267,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
            <Image
 | 
			
		||||
              alt={getAlt(attachments[2], i18n)}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
              bottomOverlay={withBottomOverlay}
 | 
			
		||||
              curveBottomLeft={curveBottomLeft}
 | 
			
		||||
              playIconOverlay={isVideoAttachment(attachments[2])}
 | 
			
		||||
              height={99}
 | 
			
		||||
| 
						 | 
				
			
			@ -272,7 +280,7 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
            <Image
 | 
			
		||||
              alt={getAlt(attachments[3], i18n)}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
              bottomOverlay={withBottomOverlay}
 | 
			
		||||
              playIconOverlay={isVideoAttachment(attachments[3])}
 | 
			
		||||
              height={99}
 | 
			
		||||
              width={98}
 | 
			
		||||
| 
						 | 
				
			
			@ -284,17 +292,13 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
            <Image
 | 
			
		||||
              alt={getAlt(attachments[4], i18n)}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              bottomOverlay={bottomOverlay && curveBottom}
 | 
			
		||||
              bottomOverlay={withBottomOverlay}
 | 
			
		||||
              curveBottomRight={curveBottomRight}
 | 
			
		||||
              playIconOverlay={isVideoAttachment(attachments[4])}
 | 
			
		||||
              height={99}
 | 
			
		||||
              width={99}
 | 
			
		||||
              darkOverlay={attachments.length > 5}
 | 
			
		||||
              overlayText={
 | 
			
		||||
                attachments.length > 5
 | 
			
		||||
                  ? `+${attachments.length - 5}`
 | 
			
		||||
                  : undefined
 | 
			
		||||
              }
 | 
			
		||||
              darkOverlay={moreMessagesOverlay}
 | 
			
		||||
              overlayText={moreMessagesOverlayText}
 | 
			
		||||
              attachment={attachments[4]}
 | 
			
		||||
              url={getThumbnailUrl(attachments[4])}
 | 
			
		||||
              onClick={onClickAttachment}
 | 
			
		||||
| 
						 | 
				
			
			@ -306,148 +310,3 @@ export class ImageGrid extends React.Component<Props> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getThumbnailUrl(attachment: AttachmentType) {
 | 
			
		||||
  if (attachment.thumbnail) {
 | 
			
		||||
    return attachment.thumbnail.url;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return getUrl(attachment);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getUrl(attachment: AttachmentType) {
 | 
			
		||||
  if (attachment.screenshot) {
 | 
			
		||||
    return attachment.screenshot.url;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return attachment.url;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isImage(attachments?: Array<AttachmentType>) {
 | 
			
		||||
  return (
 | 
			
		||||
    attachments &&
 | 
			
		||||
    attachments[0] &&
 | 
			
		||||
    attachments[0].contentType &&
 | 
			
		||||
    isImageTypeSupported(attachments[0].contentType)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isImageAttachment(attachment: AttachmentType) {
 | 
			
		||||
  return (
 | 
			
		||||
    attachment &&
 | 
			
		||||
    attachment.contentType &&
 | 
			
		||||
    isImageTypeSupported(attachment.contentType)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
export function hasImage(attachments?: Array<AttachmentType>) {
 | 
			
		||||
  return (
 | 
			
		||||
    attachments &&
 | 
			
		||||
    attachments[0] &&
 | 
			
		||||
    (attachments[0].url || attachments[0].pending)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isVideo(attachments?: Array<AttachmentType>) {
 | 
			
		||||
  return attachments && isVideoAttachment(attachments[0]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isVideoAttachment(attachment?: AttachmentType) {
 | 
			
		||||
  return (
 | 
			
		||||
    attachment &&
 | 
			
		||||
    attachment.contentType &&
 | 
			
		||||
    isVideoTypeSupported(attachment.contentType)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function hasVideoScreenshot(attachments?: Array<AttachmentType>) {
 | 
			
		||||
  const firstAttachment = attachments ? attachments[0] : null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    firstAttachment &&
 | 
			
		||||
    firstAttachment.screenshot &&
 | 
			
		||||
    firstAttachment.screenshot.url
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DimensionsType = {
 | 
			
		||||
  height: number;
 | 
			
		||||
  width: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function getImageDimensions(attachment: AttachmentType): DimensionsType {
 | 
			
		||||
  const { height, width } = attachment;
 | 
			
		||||
  if (!height || !width) {
 | 
			
		||||
    return {
 | 
			
		||||
      height: MIN_HEIGHT,
 | 
			
		||||
      width: MIN_WIDTH,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const aspectRatio = height / width;
 | 
			
		||||
  const targetWidth = Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH);
 | 
			
		||||
  const candidateHeight = Math.round(targetWidth * aspectRatio);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    width: targetWidth,
 | 
			
		||||
    height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function areAllAttachmentsVisual(
 | 
			
		||||
  attachments?: Array<AttachmentType>
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (!attachments) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const max = attachments.length;
 | 
			
		||||
  for (let i = 0; i < max; i += 1) {
 | 
			
		||||
    const attachment = attachments[i];
 | 
			
		||||
    if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getGridDimensions(
 | 
			
		||||
  attachments?: Array<AttachmentType>
 | 
			
		||||
): null | DimensionsType {
 | 
			
		||||
  if (!attachments || !attachments.length) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!isImage(attachments) && !isVideo(attachments)) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (attachments.length === 1) {
 | 
			
		||||
    return getImageDimensions(attachments[0]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (attachments.length === 2) {
 | 
			
		||||
    return {
 | 
			
		||||
      height: 150,
 | 
			
		||||
      width: 300,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (attachments.length === 4) {
 | 
			
		||||
    return {
 | 
			
		||||
      height: 300,
 | 
			
		||||
      width: 300,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    height: 200,
 | 
			
		||||
    width: 300,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getAlt(attachment: AttachmentType, i18n: Localizer): string {
 | 
			
		||||
  return isVideoAttachment(attachment)
 | 
			
		||||
    ? i18n('videoAttachmentAlt')
 | 
			
		||||
    : i18n('imageAttachmentAlt');
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import React from 'react';
 | 
			
		|||
 | 
			
		||||
import LinkifyIt from 'linkify-it';
 | 
			
		||||
 | 
			
		||||
import { RenderTextCallback } from '../../types/Util';
 | 
			
		||||
import { RenderTextCallbackType } from '../../types/Util';
 | 
			
		||||
import { isLinkSneaky } from '../../../js/modules/link_previews';
 | 
			
		||||
 | 
			
		||||
const linkify = LinkifyIt();
 | 
			
		||||
| 
						 | 
				
			
			@ -10,7 +10,7 @@ const linkify = LinkifyIt();
 | 
			
		|||
interface Props {
 | 
			
		||||
  text: string;
 | 
			
		||||
  /** Allows you to customize now non-links are rendered. Simplest is just a <span>. */
 | 
			
		||||
  renderNonLink?: RenderTextCallback;
 | 
			
		||||
  renderNonLink?: RenderTextCallbackType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,28 +4,32 @@ import classNames from 'classnames';
 | 
			
		|||
import { Avatar } from '../Avatar';
 | 
			
		||||
import { Spinner } from '../Spinner';
 | 
			
		||||
import { MessageBody } from './MessageBody';
 | 
			
		||||
import { ExpireTimer, getIncrement } from './ExpireTimer';
 | 
			
		||||
import {
 | 
			
		||||
  getGridDimensions,
 | 
			
		||||
  getImageDimensions,
 | 
			
		||||
  hasImage,
 | 
			
		||||
  hasVideoScreenshot,
 | 
			
		||||
  ImageGrid,
 | 
			
		||||
  isImage,
 | 
			
		||||
  isImageAttachment,
 | 
			
		||||
  isVideo,
 | 
			
		||||
} from './ImageGrid';
 | 
			
		||||
import { ExpireTimer } from './ExpireTimer';
 | 
			
		||||
import { ImageGrid } from './ImageGrid';
 | 
			
		||||
import { Image } from './Image';
 | 
			
		||||
import { Timestamp } from './Timestamp';
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
import { Quote, QuotedAttachmentType } from './Quote';
 | 
			
		||||
import { EmbeddedContact } from './EmbeddedContact';
 | 
			
		||||
import * as MIME from '../../../ts/types/MIME';
 | 
			
		||||
 | 
			
		||||
import { AttachmentType } from './types';
 | 
			
		||||
import { isFileDangerous } from '../../util/isFileDangerous';
 | 
			
		||||
import {
 | 
			
		||||
  canDisplayImage,
 | 
			
		||||
  getExtensionForDisplay,
 | 
			
		||||
  getGridDimensions,
 | 
			
		||||
  getImageDimensions,
 | 
			
		||||
  hasImage,
 | 
			
		||||
  hasVideoScreenshot,
 | 
			
		||||
  isAudio,
 | 
			
		||||
  isImage,
 | 
			
		||||
  isImageAttachment,
 | 
			
		||||
  isVideo,
 | 
			
		||||
} from '../../../ts/types/Attachment';
 | 
			
		||||
import { AttachmentType } from '../../types/Attachment';
 | 
			
		||||
import { Contact } from '../../types/Contact';
 | 
			
		||||
import { Color, Localizer } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
import { getIncrement } from '../../util/timer';
 | 
			
		||||
import { isFileDangerous } from '../../util/isFileDangerous';
 | 
			
		||||
import { ColorType, LocalizerType } from '../../types/Util';
 | 
			
		||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
 | 
			
		||||
 | 
			
		||||
interface Trigger {
 | 
			
		||||
| 
						 | 
				
			
			@ -56,12 +60,12 @@ export interface Props {
 | 
			
		|||
    onSendMessage?: () => void;
 | 
			
		||||
    onClick?: () => void;
 | 
			
		||||
  };
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  authorName?: string;
 | 
			
		||||
  authorProfileName?: string;
 | 
			
		||||
  /** Note: this should be formatted for display */
 | 
			
		||||
  authorPhoneNumber: string;
 | 
			
		||||
  authorColor?: Color;
 | 
			
		||||
  authorColor?: ColorType;
 | 
			
		||||
  conversationType: 'group' | 'direct';
 | 
			
		||||
  attachments?: Array<AttachmentType>;
 | 
			
		||||
  quote?: {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +75,7 @@ export interface Props {
 | 
			
		|||
    authorPhoneNumber: string;
 | 
			
		||||
    authorProfileName?: string;
 | 
			
		||||
    authorName?: string;
 | 
			
		||||
    authorColor?: Color;
 | 
			
		||||
    authorColor?: ColorType;
 | 
			
		||||
    onClick?: () => void;
 | 
			
		||||
    referencedMessageNotFound: boolean;
 | 
			
		||||
  };
 | 
			
		||||
| 
						 | 
				
			
			@ -98,12 +102,12 @@ interface State {
 | 
			
		|||
const EXPIRATION_CHECK_MINIMUM = 2000;
 | 
			
		||||
const EXPIRED_DELAY = 600;
 | 
			
		||||
 | 
			
		||||
export class Message extends React.Component<Props, State> {
 | 
			
		||||
export class Message extends React.PureComponent<Props, State> {
 | 
			
		||||
  public captureMenuTriggerBound: (trigger: any) => void;
 | 
			
		||||
  public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
 | 
			
		||||
  public handleImageErrorBound: () => void;
 | 
			
		||||
 | 
			
		||||
  public menuTriggerRef: Trigger | null;
 | 
			
		||||
  public menuTriggerRef: Trigger | undefined;
 | 
			
		||||
  public expirationCheckInterval: any;
 | 
			
		||||
  public expiredTimeout: any;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -114,10 +118,6 @@ export class Message extends React.Component<Props, State> {
 | 
			
		|||
    this.showMenuBound = this.showMenu.bind(this);
 | 
			
		||||
    this.handleImageErrorBound = this.handleImageError.bind(this);
 | 
			
		||||
 | 
			
		||||
    this.menuTriggerRef = null;
 | 
			
		||||
    this.expirationCheckInterval = null;
 | 
			
		||||
    this.expiredTimeout = null;
 | 
			
		||||
 | 
			
		||||
    this.state = {
 | 
			
		||||
      expiring: false,
 | 
			
		||||
      expired: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -366,7 +366,7 @@ export class Message extends React.Component<Props, State> {
 | 
			
		|||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      const { pending, fileName, fileSize, contentType } = firstAttachment;
 | 
			
		||||
      const extension = getExtension({ contentType, fileName });
 | 
			
		||||
      const extension = getExtensionForDisplay({ contentType, fileName });
 | 
			
		||||
      const isDangerous = isFileDangerous(fileName || '');
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
| 
						 | 
				
			
			@ -851,7 +851,7 @@ export class Message extends React.Component<Props, State> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getWidth(): Number | undefined {
 | 
			
		||||
  public getWidth(): number | undefined {
 | 
			
		||||
    const { attachments, previews } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (attachments && attachments.length) {
 | 
			
		||||
| 
						 | 
				
			
			@ -976,53 +976,3 @@ export class Message extends React.Component<Props, State> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export 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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!contentType) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const slash = contentType.indexOf('/');
 | 
			
		||||
  if (slash >= 0) {
 | 
			
		||||
    return contentType.slice(slash + 1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isAudio(attachments?: Array<AttachmentType>) {
 | 
			
		||||
  return (
 | 
			
		||||
    attachments &&
 | 
			
		||||
    attachments[0] &&
 | 
			
		||||
    attachments[0].contentType &&
 | 
			
		||||
    MIME.isAudio(attachments[0].contentType)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function canDisplayImage(attachments?: Array<AttachmentType>) {
 | 
			
		||||
  const { height, width } =
 | 
			
		||||
    attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    height &&
 | 
			
		||||
    height > 0 &&
 | 
			
		||||
    height <= 4096 &&
 | 
			
		||||
    width &&
 | 
			
		||||
    width > 0 &&
 | 
			
		||||
    width <= 4096
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { Emojify } from './Emojify';
 | 
			
		|||
import { AddNewLines } from './AddNewLines';
 | 
			
		||||
import { Linkify } from './Linkify';
 | 
			
		||||
 | 
			
		||||
import { Localizer, RenderTextCallback } from '../../types/Util';
 | 
			
		||||
import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  text: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -13,10 +13,10 @@ interface Props {
 | 
			
		|||
  disableJumbomoji?: boolean;
 | 
			
		||||
  /** If set, links will be left alone instead of turned into clickable `<a>` tags. */
 | 
			
		||||
  disableLinks?: boolean;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const renderNewLines: RenderTextCallback = ({
 | 
			
		||||
const renderNewLines: RenderTextCallbackType = ({
 | 
			
		||||
  text: textWithNewLines,
 | 
			
		||||
  key,
 | 
			
		||||
}) => <AddNewLines key={key} text={textWithNewLines} />;
 | 
			
		||||
| 
						 | 
				
			
			@ -28,11 +28,11 @@ const renderEmoji = ({
 | 
			
		|||
  sizeClass,
 | 
			
		||||
  renderNonEmoji,
 | 
			
		||||
}: {
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  text: string;
 | 
			
		||||
  key: number;
 | 
			
		||||
  sizeClass?: SizeClassType;
 | 
			
		||||
  renderNonEmoji: RenderTextCallback;
 | 
			
		||||
  renderNonEmoji: RenderTextCallbackType;
 | 
			
		||||
}) => (
 | 
			
		||||
  <Emojify
 | 
			
		||||
    i18n={i18n}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import moment from 'moment';
 | 
			
		|||
import { Avatar } from '../Avatar';
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
import { Message, Props as MessageProps } from './Message';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Contact {
 | 
			
		||||
  status: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +31,7 @@ interface Props {
 | 
			
		|||
  errors: Array<Error>;
 | 
			
		||||
  contacts: Array<Contact>;
 | 
			
		||||
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class MessageDetail extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,7 @@ import * as MIME from '../../../ts/types/MIME';
 | 
			
		|||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
 | 
			
		||||
 | 
			
		||||
import { MessageBody } from './MessageBody';
 | 
			
		||||
import { Color, Localizer } from '../../types/Util';
 | 
			
		||||
import { ColorType, LocalizerType } from '../../types/Util';
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
| 
						 | 
				
			
			@ -15,8 +15,8 @@ interface Props {
 | 
			
		|||
  authorPhoneNumber: string;
 | 
			
		||||
  authorProfileName?: string;
 | 
			
		||||
  authorName?: string;
 | 
			
		||||
  authorColor?: Color;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  authorColor?: ColorType;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  isFromMe: boolean;
 | 
			
		||||
  isIncoming: boolean;
 | 
			
		||||
  withContentAbove: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -56,12 +56,12 @@ function validateQuote(quote: Props): boolean {
 | 
			
		|||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getObjectUrl(thumbnail: Attachment | undefined): string | null {
 | 
			
		||||
function getObjectUrl(thumbnail: Attachment | undefined): string | undefined {
 | 
			
		||||
  if (thumbnail && thumbnail.objectUrl) {
 | 
			
		||||
    return thumbnail.objectUrl;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
  return;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getTypeLabel({
 | 
			
		||||
| 
						 | 
				
			
			@ -69,10 +69,10 @@ function getTypeLabel({
 | 
			
		|||
  contentType,
 | 
			
		||||
  isVoiceMessage,
 | 
			
		||||
}: {
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  contentType: MIME.MIMEType;
 | 
			
		||||
  isVoiceMessage: boolean;
 | 
			
		||||
}): string | null {
 | 
			
		||||
}): string | undefined {
 | 
			
		||||
  if (GoogleChrome.isVideoTypeSupported(contentType)) {
 | 
			
		||||
    return i18n('video');
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +86,7 @@ function getTypeLabel({
 | 
			
		|||
    return i18n('audio');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
  return;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Quote extends React.Component<Props, State> {
 | 
			
		||||
| 
						 | 
				
			
			@ -110,7 +110,7 @@ export class Quote extends React.Component<Props, State> {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public renderImage(url: string, i18n: Localizer, icon?: string) {
 | 
			
		||||
  public renderImage(url: string, i18n: LocalizerType, icon?: string) {
 | 
			
		||||
    const iconElement = icon ? (
 | 
			
		||||
      <div className="module-quote__icon-container__inner">
 | 
			
		||||
        <div className="module-quote__icon-container__circle-background">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,9 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class ResetSessionNotification extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ import React from 'react';
 | 
			
		|||
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
import { Intl } from '../Intl';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Contact {
 | 
			
		||||
  phoneNumber: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -14,20 +14,23 @@ interface Contact {
 | 
			
		|||
interface Props {
 | 
			
		||||
  isGroup: boolean;
 | 
			
		||||
  contact: Contact;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onVerify: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SafetyNumberNotification extends React.Component<Props> {
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { contact, isGroup, i18n, onVerify } = this.props;
 | 
			
		||||
    const changeKey = isGroup
 | 
			
		||||
      ? 'safetyNumberChangedGroup'
 | 
			
		||||
      : 'safetyNumberChanged';
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-safety-number-notification">
 | 
			
		||||
        <div className="module-safety-number-notification__icon" />
 | 
			
		||||
        <div className="module-safety-number-notification__text">
 | 
			
		||||
          <Intl
 | 
			
		||||
            id={isGroup ? 'safetyNumberChangedGroup' : 'safetyNumberChanged'}
 | 
			
		||||
            id={changeKey}
 | 
			
		||||
            components={[
 | 
			
		||||
              <span
 | 
			
		||||
                key="external-1"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,21 +1,19 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { getExtension } from './Message';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { AttachmentType } from './types';
 | 
			
		||||
import { AttachmentType, getExtensionForDisplay } from '../../types/Attachment';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  attachment: AttachmentType;
 | 
			
		||||
  onClose: (attachment: AttachmentType) => void;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class StagedGenericAttachment extends React.Component<Props> {
 | 
			
		||||
  public render() {
 | 
			
		||||
    const { attachment, onClose } = this.props;
 | 
			
		||||
    const { fileName, contentType } = attachment;
 | 
			
		||||
    const extension = getExtension({ contentType, fileName });
 | 
			
		||||
    const extension = getExtensionForDisplay({ contentType, fileName });
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-staged-generic-attachment">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,10 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { isImageAttachment } from './ImageGrid';
 | 
			
		||||
import { Image } from './Image';
 | 
			
		||||
import { AttachmentType } from './types';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { AttachmentType, isImageAttachment } from '../../types/Attachment';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  isLoaded: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -13,7 +12,7 @@ interface Props {
 | 
			
		|||
  domain: string;
 | 
			
		||||
  image?: AttachmentType;
 | 
			
		||||
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onClose?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ import classNames from 'classnames';
 | 
			
		|||
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
import { Intl } from '../Intl';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
import { missingCaseError } from '../../util/missingCaseError';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -14,7 +14,7 @@ interface Props {
 | 
			
		|||
  name?: string;
 | 
			
		||||
  disabled: boolean;
 | 
			
		||||
  timespan: string;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class TimerNotification extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			@ -28,15 +28,16 @@ export class TimerNotification extends React.Component<Props> {
 | 
			
		|||
      type,
 | 
			
		||||
      disabled,
 | 
			
		||||
    } = this.props;
 | 
			
		||||
    const changeKey = disabled
 | 
			
		||||
      ? 'disabledDisappearingMessages'
 | 
			
		||||
      : 'theyChangedTheTimer';
 | 
			
		||||
 | 
			
		||||
    switch (type) {
 | 
			
		||||
      case 'fromOther':
 | 
			
		||||
        return (
 | 
			
		||||
          <Intl
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            id={
 | 
			
		||||
              disabled ? 'disabledDisappearingMessages' : 'theyChangedTheTimer'
 | 
			
		||||
            }
 | 
			
		||||
            id={changeKey}
 | 
			
		||||
            components={[
 | 
			
		||||
              <ContactName
 | 
			
		||||
                i18n={i18n}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,15 +4,15 @@ import moment from 'moment';
 | 
			
		|||
 | 
			
		||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  timestamp: number | null;
 | 
			
		||||
  extended: boolean;
 | 
			
		||||
  timestamp?: number;
 | 
			
		||||
  extended?: boolean;
 | 
			
		||||
  module?: string;
 | 
			
		||||
  withImageNoCaption?: boolean;
 | 
			
		||||
  direction?: 'incoming' | 'outgoing';
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const UPDATE_FREQUENCY = 60 * 1000;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,10 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  color?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,7 @@ import classNames from 'classnames';
 | 
			
		|||
import { TypingAnimation } from './TypingAnimation';
 | 
			
		||||
import { Avatar } from '../Avatar';
 | 
			
		||||
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  avatarPath?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -13,7 +13,7 @@ interface Props {
 | 
			
		|||
  phoneNumber: string;
 | 
			
		||||
  profileName: string;
 | 
			
		||||
  conversationType: string;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class TypingBubble extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ import React from 'react';
 | 
			
		|||
 | 
			
		||||
import { ContactName } from './ContactName';
 | 
			
		||||
import { Intl } from '../Intl';
 | 
			
		||||
import { Localizer } from '../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
 | 
			
		||||
import { missingCaseError } from '../../util/missingCaseError';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ interface Props {
 | 
			
		|||
  type: 'markVerified' | 'markNotVerified';
 | 
			
		||||
  isLocal: boolean;
 | 
			
		||||
  contact: Contact;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class VerificationNotification extends React.Component<Props> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										93
									
								
								ts/components/conversation/_contactUtil.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								ts/components/conversation/_contactUtil.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,93 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from '../Avatar';
 | 
			
		||||
import { Spinner } from '../Spinner';
 | 
			
		||||
 | 
			
		||||
import { LocalizerType } from '../../types/Util';
 | 
			
		||||
import { Contact, getName } from '../../types/Contact';
 | 
			
		||||
 | 
			
		||||
// This file starts with _ to keep it from showing up in the StyleGuide.
 | 
			
		||||
 | 
			
		||||
export function renderAvatar({
 | 
			
		||||
  contact,
 | 
			
		||||
  i18n,
 | 
			
		||||
  size,
 | 
			
		||||
  direction,
 | 
			
		||||
}: {
 | 
			
		||||
  contact: Contact;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  size: number;
 | 
			
		||||
  direction?: string;
 | 
			
		||||
}) {
 | 
			
		||||
  const { avatar } = contact;
 | 
			
		||||
 | 
			
		||||
  const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
 | 
			
		||||
  const pending = avatar && avatar.avatar && avatar.avatar.pending;
 | 
			
		||||
  const name = getName(contact) || '';
 | 
			
		||||
 | 
			
		||||
  if (pending) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="module-embedded-contact__spinner-container">
 | 
			
		||||
        <Spinner small={size < 50} direction={direction} />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Avatar
 | 
			
		||||
      avatarPath={avatarPath}
 | 
			
		||||
      color="grey"
 | 
			
		||||
      conversationType="direct"
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      name={name}
 | 
			
		||||
      size={size}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
  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={classNames(
 | 
			
		||||
        `module-${module}__contact-method`,
 | 
			
		||||
        isIncoming ? `module-${module}__contact-method--incoming` : null
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      {firstNumber || firstEmail}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,10 +5,10 @@ import { ItemClickEvent } from './types/ItemClickEvent';
 | 
			
		|||
import { MediaGridItem } from './MediaGridItem';
 | 
			
		||||
import { MediaItemType } from '../../LightboxGallery';
 | 
			
		||||
import { missingCaseError } from '../../../util/missingCaseError';
 | 
			
		||||
import { Localizer } from '../../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../../types/Util';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  header?: string;
 | 
			
		||||
  type: 'media' | 'documents';
 | 
			
		||||
  mediaItems: Array<MediaItemType>;
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +64,7 @@ export class AttachmentSection extends React.Component<Props> {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private createClickHandler = (mediaItem: MediaItemType) => () => {
 | 
			
		||||
  private readonly createClickHandler = (mediaItem: MediaItemType) => () => {
 | 
			
		||||
    const { onItemClick, type } = this.props;
 | 
			
		||||
    const { message, attachment } = mediaItem;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,7 @@ interface Props {
 | 
			
		|||
  timestamp: number;
 | 
			
		||||
 | 
			
		||||
  // Optional
 | 
			
		||||
  fileName?: string | null;
 | 
			
		||||
  fileName?: string;
 | 
			
		||||
  fileSize?: number;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
  shouldShowSeparator?: boolean;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,13 +8,13 @@ import { EmptyState } from './EmptyState';
 | 
			
		|||
import { groupMediaItemsByDate } from './groupMediaItemsByDate';
 | 
			
		||||
import { ItemClickEvent } from './types/ItemClickEvent';
 | 
			
		||||
import { missingCaseError } from '../../../util/missingCaseError';
 | 
			
		||||
import { Localizer } from '../../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../../types/Util';
 | 
			
		||||
 | 
			
		||||
import { MediaItemType } from '../../LightboxGallery';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  documents: Array<MediaItemType>;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  media: Array<MediaItemType>;
 | 
			
		||||
  onItemClick?: (event: ItemClickEvent) => void;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +91,7 @@ export class MediaGallery extends React.Component<Props, State> {
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private handleTabSelect = (event: TabSelectEvent): void => {
 | 
			
		||||
  private readonly handleTabSelect = (event: TabSelectEvent): void => {
 | 
			
		||||
    this.setState({ selectedTab: event.type });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,13 +5,13 @@ import {
 | 
			
		|||
  isImageTypeSupported,
 | 
			
		||||
  isVideoTypeSupported,
 | 
			
		||||
} from '../../../util/GoogleChrome';
 | 
			
		||||
import { Localizer } from '../../../types/Util';
 | 
			
		||||
import { LocalizerType } from '../../../types/Util';
 | 
			
		||||
import { MediaItemType } from '../../LightboxGallery';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  mediaItem: MediaItemType;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
  i18n: Localizer;
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface State {
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +19,7 @@ interface State {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export class MediaGridItem extends React.Component<Props, State> {
 | 
			
		||||
  private onImageErrorBound: () => void;
 | 
			
		||||
  private readonly onImageErrorBound: () => void;
 | 
			
		||||
 | 
			
		||||
  constructor(props: Props) {
 | 
			
		||||
    super(props);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -48,15 +48,15 @@ export const groupMediaItemsByDate = (
 | 
			
		|||
 | 
			
		||||
const toSection = (
 | 
			
		||||
  messagesWithSection: Array<MediaItemWithSection> | undefined
 | 
			
		||||
): Section | null => {
 | 
			
		||||
): Section | undefined => {
 | 
			
		||||
  if (!messagesWithSection || messagesWithSection.length === 0) {
 | 
			
		||||
    return null;
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const firstMediaItemWithSection: MediaItemWithSection =
 | 
			
		||||
    messagesWithSection[0];
 | 
			
		||||
  if (!firstMediaItemWithSection) {
 | 
			
		||||
    return null;
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const mediaItems = messagesWithSection.map(
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +83,7 @@ const toSection = (
 | 
			
		|||
      // error TS2345: Argument of type 'any' is not assignable to parameter
 | 
			
		||||
      // of type 'never'.
 | 
			
		||||
      // return missingCaseError(firstMediaItemWithSection.type);
 | 
			
		||||
      return null;
 | 
			
		||||
      return;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { AttachmentType } from '../../types';
 | 
			
		||||
import { AttachmentType } from '../../../../types/Attachment';
 | 
			
		||||
import { Message } from './Message';
 | 
			
		||||
 | 
			
		||||
export interface ItemClickEvent {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue