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 |
|
@ -1 +1 @@
|
|||
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>x-16</title><polygon points="14.35 2.35 13.65 1.65 8 7.29 2.35 1.65 1.65 2.35 7.29 8 1.65 13.65 2.35 14.35 8 8.71 13.65 14.35 14.35 13.65 8.71 8 14.35 2.35"/></svg>
|
||||
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>x-16</title><polygon points="14.35 2.35 13.65 1.65 8 7.29 2.35 1.65 1.65 2.35 7.29 8 1.65 13.65 2.35 14.35 8 8.71 13.65 14.35 14.35 13.65 8.71 8 14.35 2.35"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 243 B |
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M38 12.83L35.17 10 24 21.17 12.83 10 10 12.83 21.17 24 10 35.17 12.83 38 24 26.83 35.17 38 38 35.17 26.83 24z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M38 12.83L35.17 10 24 21.17 12.83 10 10 12.83 21.17 24 10 35.17 12.83 38 24 26.83 35.17 38 38 35.17 26.83 24z"/></svg>
|
||||
|
|
Before Width: | Height: | Size: 210 B After Width: | Height: | Size: 211 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)
|
||||
);
|
||||
this.focusConversation();
|
||||
async openConversation(id, messageId) {
|
||||
const conversation = await window.ConversationController.getOrCreateAndWait(
|
||||
id,
|
||||
'private'
|
||||
);
|
||||
|
||||
if (this.openConversationAction) {
|
||||
this.openConversationAction(id, messageId);
|
||||
}
|
||||
|
||||
this.conversation_stack.open(conversation);
|
||||
this.focusConversation();
|
||||
},
|
||||
closeRecording(e) {
|
||||
if (e && this.$(e.target).closest('.capture-audio').length > 0) {
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
433
test/index.html
433
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='network-status-container'></div>
|
||||
<div class='left-pane-placeholder'></div>
|
||||
</div>
|
||||
<div class='conversation-stack'>
|
||||
<div class='conversation placeholder'>
|
||||
<div class='conversation-header'></div>
|
||||
<div class='content'>
|
||||
<img src='/images/icon_128.png' />
|
||||
<h3>{{ welcomeToSignal }}</h3>
|
||||
<p>{{ selectAContact }}</p>
|
||||
<div class='container'>
|
||||
<div class='content'>
|
||||
<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'>
|
||||
{{ unreadMessages }}
|
||||
</div>
|
||||
<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>
|
||||
|
@ -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>
|
||||
</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 }}
|
||||
</div>
|
||||
<input type='file' name='avatar' class='file-input'>
|
||||
|
||||
<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='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 class='nav'>
|
||||
<div>
|
||||
<a class='button neutral cancel'>{{ cancelButton }}</a>
|
||||
<a class='button destructive delete-all-data'>{{ deleteButton }}</a>
|
||||
</div>
|
||||
</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>
|
||||
{{/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='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);
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue