Move left pane entirely to React

This commit is contained in:
Scott Nonnenberg 2019-01-14 13:49:58 -08:00
parent bf904ddd12
commit b3ac1373fa
142 changed files with 5016 additions and 3428 deletions

View File

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

View File

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

View File

@ -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 }} &middot;{{ /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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -0,0 +1,2 @@
export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(query: string): Promise<Array<any>>;

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ const {
// contentType: MIMEType
// data: ArrayBuffer
// digest: ArrayBuffer
// fileName: string | null
// fileName?: string
// flags: null
// key: ArrayBuffer
// size: integer

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]*$/);
},
});
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -91,7 +91,6 @@ button.emoji {
margin-top: 3px;
&:before {
margin-top: 4px;
content: '';
display: inline-block;
width: $button-height;

View File

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

View File

@ -74,6 +74,13 @@
}
}
.left-pane-placeholder {
height: 100%;
}
.left-pane-wrapper {
height: 100%;
}
.conversation-stack {
.conversation {
display: none;

View File

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

View File

@ -17,7 +17,6 @@
}
&:before {
margin-top: 4px;
content: '';
display: inline-block;
height: 24px;

View File

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

View File

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

View File

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

View File

@ -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 }} &middot;{{ /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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}
}

View File

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

View File

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

View File

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

View File

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

View 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} />
```

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

View 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>
```

View 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>
);
}
}

View 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>;
```

View 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>
);
}
}

View 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>
```

View 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>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

@ -10,7 +10,7 @@ interface Props {
timestamp: number;
// Optional
fileName?: string | null;
fileName?: string;
fileSize?: number;
onClick?: () => void;
shouldShowSeparator?: boolean;

View File

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

View File

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