Virtualize Messages List - only render what's visible

This commit is contained in:
Scott Nonnenberg 2019-05-31 15:42:01 -07:00
parent a976cfe6b6
commit 5ebd8bc690
73 changed files with 4717 additions and 2745 deletions

View file

@ -309,11 +309,6 @@
"description": "description":
"Alt text for button to take user down to bottom of conversation, shown when user scrolls up" "Alt text for button to take user down to bottom of conversation, shown when user scrolls up"
}, },
"messageBelow": {
"message": "New message below",
"description":
"Alt text for button to take user down to bottom of conversation with a new message out of screen"
},
"messagesBelow": { "messagesBelow": {
"message": "New messages below", "message": "New messages below",
"description": "description":

View file

@ -16,6 +16,7 @@ const {
isString, isString,
last, last,
map, map,
pick,
} = require('lodash'); } = require('lodash');
// To get long stack traces // To get long stack traces
@ -93,9 +94,11 @@ module.exports = {
getExpiredMessages, getExpiredMessages,
getOutgoingWithoutExpiresAt, getOutgoingWithoutExpiresAt,
getNextExpiringMessage, getNextExpiringMessage,
getMessagesByConversation,
getNextTapToViewMessageToAgeOut, getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase, getTapToViewMessagesNeedingErase,
getOlderMessagesByConversation,
getNewerMessagesByConversation,
getMessageMetricsForConversation,
getUnprocessedCount, getUnprocessedCount,
getAllUnprocessed, getAllUnprocessed,
@ -1840,7 +1843,7 @@ async function getUnreadByConversation(conversationId) {
return map(rows, row => jsonToObject(row.json)); return map(rows, row => jsonToObject(row.json));
} }
async function getMessagesByConversation( async function getOlderMessagesByConversation(
conversationId, conversationId,
{ limit = 100, receivedAt = Number.MAX_VALUE } = {} { limit = 100, receivedAt = Number.MAX_VALUE } = {}
) { ) {
@ -1857,8 +1860,118 @@ async function getMessagesByConversation(
} }
); );
return map(rows.reverse(), row => jsonToObject(row.json));
}
async function getNewerMessagesByConversation(
conversationId,
{ limit = 100, receivedAt = 0 } = {}
) {
const rows = await db.all(
`SELECT json FROM messages WHERE
conversationId = $conversationId AND
received_at > $received_at
ORDER BY received_at ASC
LIMIT $limit;`,
{
$conversationId: conversationId,
$received_at: receivedAt,
$limit: limit,
}
);
return map(rows, row => jsonToObject(row.json)); return map(rows, row => jsonToObject(row.json));
} }
async function getOldestMessageForConversation(conversationId) {
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId
ORDER BY received_at ASC
LIMIT 1;`,
{
$conversationId: conversationId,
}
);
if (!row) {
return null;
}
return row;
}
async function getNewestMessageForConversation(conversationId) {
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId
ORDER BY received_at DESC
LIMIT 1;`,
{
$conversationId: conversationId,
}
);
if (!row) {
return null;
}
return row;
}
async function getOldestUnreadMessageForConversation(conversationId) {
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId AND
unread = 1
ORDER BY received_at ASC
LIMIT 1;`,
{
$conversationId: conversationId,
}
);
if (!row) {
return null;
}
return row;
}
async function getTotalUnreadForConversation(conversationId) {
const row = await db.get(
`SELECT count(id) from messages WHERE
conversationId = $conversationId AND
unread = 1;
`,
{
$conversationId: conversationId,
}
);
if (!row) {
throw new Error('getTotalUnreadForConversation: Unable to get count');
}
return row['count(id)'];
}
async function getMessageMetricsForConversation(conversationId) {
const results = await Promise.all([
getOldestMessageForConversation(conversationId),
getNewestMessageForConversation(conversationId),
getOldestUnreadMessageForConversation(conversationId),
getTotalUnreadForConversation(conversationId),
]);
const [oldest, newest, oldestUnread, totalUnread] = results;
return {
oldest: oldest ? pick(oldest, ['received_at', 'id']) : null,
newest: newest ? pick(newest, ['received_at', 'id']) : null,
oldestUnread: oldestUnread
? pick(oldestUnread, ['received_at', 'id'])
: null,
totalUnread,
};
}
async function getMessagesBySentAt(sentAt) { async function getMessagesBySentAt(sentAt) {
const rows = await db.all( const rows = await db.all(

View file

@ -71,19 +71,6 @@
<div class='lightbox-container'></div> <div class='lightbox-container'></div>
</script> </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'> <script type='text/x-tmpl-mustache' id='expired_alert'>
<a target='_blank' href='https://signal.org/download/'> <a target='_blank' href='https://signal.org/download/'>
<button class='upgrade'>{{ upgrade }}</button> <button class='upgrade'>{{ upgrade }}</button>
@ -106,12 +93,7 @@
<script type='text/x-tmpl-mustache' id='conversation'> <script type='text/x-tmpl-mustache' id='conversation'>
<div class='conversation-header'></div> <div class='conversation-header'></div>
<div class='main panel'> <div class='main panel'>
<div class='discussion-container'> <div class='timeline-placeholder'></div>
<div class='bar-container hide'>
<div class='bar active progress-bar-striped progress-bar'></div>
</div>
</div>
<div class='bottom-bar' id='footer'> <div class='bottom-bar' id='footer'>
<div class='compose'> <div class='compose'>
<form class='send clearfix file-input'> <form class='send clearfix file-input'>
@ -488,15 +470,11 @@
<script type='text/javascript' src='js/views/react_wrapper_view.js'></script> <script type='text/javascript' src='js/views/react_wrapper_view.js'></script>
<script type='text/javascript' src='js/views/whisper_view.js'></script> <script type='text/javascript' src='js/views/whisper_view.js'></script>
<script type='text/javascript' src='js/views/last_seen_indicator_view.js'></script>
<script type='text/javascript' src='js/views/scroll_down_button_view.js'></script>
<script type='text/javascript' src='js/views/toast_view.js'></script> <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/file_input_view.js'></script>
<script type='text/javascript' src='js/views/list_view.js'></script> <script type='text/javascript' src='js/views/list_view.js'></script>
<script type='text/javascript' src='js/views/contact_list_view.js'></script> <script type='text/javascript' src='js/views/contact_list_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/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/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_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_view.js'></script>

View file

@ -476,6 +476,12 @@
const initialState = { const initialState = {
conversations: { conversations: {
conversationLookup: Signal.Util.makeLookup(conversations, 'id'), conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
messagesByConversation: {},
messagesLookup: {},
selectedConversation: null,
selectedMessage: null,
selectedMessageCounter: 0,
showArchived: false,
}, },
emojis: Signal.Emojis.getInitialState(), emojis: Signal.Emojis.getInitialState(),
items: storage.getItemsState(), items: storage.getItemsState(),

View file

@ -18,7 +18,6 @@
'add remove change:unreadCount', 'add remove change:unreadCount',
_.debounce(this.updateUnreadCount.bind(this), 1000) _.debounce(this.updateUnreadCount.bind(this), 1000)
); );
this.startPruning();
}, },
addActive(model) { addActive(model) {
if (model.get('active_at')) { if (model.get('active_at')) {
@ -44,14 +43,6 @@
} }
window.updateTrayIcon(newUnreadCount); window.updateTrayIcon(newUnreadCount);
}, },
startPruning() {
const halfHour = 30 * 60 * 1000;
this.interval = setInterval(() => {
this.forEach(conversation => {
conversation.trigger('prune');
});
}, halfHour);
},
}))(); }))();
window.getInboxCollection = () => inboxCollection; window.getInboxCollection = () => inboxCollection;

View file

@ -27,13 +27,7 @@
}; };
const { Util } = window.Signal; const { Util } = window.Signal;
const { const { Conversation, Contact, Message, PhoneNumber } = window.Signal.Types;
Conversation,
Contact,
Errors,
Message,
PhoneNumber,
} = window.Signal.Types;
const { const {
deleteAttachmentData, deleteAttachmentData,
getAbsoluteAttachmentPath, getAbsoluteAttachmentPath,
@ -277,6 +271,7 @@
this.messageCollection.remove(id); this.messageCollection.remove(id);
existing.trigger('expired'); existing.trigger('expired');
existing.cleanup();
}; };
// If a fetch is in progress, then we need to wait until that's complete to // If a fetch is in progress, then we need to wait until that's complete to
@ -288,18 +283,33 @@
}, },
async onNewMessage(message) { async onNewMessage(message) {
await this.updateLastMessage();
// Clear typing indicator for a given contact if we receive a message from them // Clear typing indicator for a given contact if we receive a message from them
const identifier = message.get const identifier = message.get
? `${message.get('source')}.${message.get('sourceDevice')}` ? `${message.get('source')}.${message.get('sourceDevice')}`
: `${message.source}.${message.sourceDevice}`; : `${message.source}.${message.sourceDevice}`;
this.clearContactTypingTimer(identifier); this.clearContactTypingTimer(identifier);
await this.updateLastMessage();
}, },
addSingleMessage(message) { addSingleMessage(message) {
const { id } = message;
const existing = this.messageCollection.get(id);
const model = this.messageCollection.add(message, { merge: true }); const model = this.messageCollection.add(message, { merge: true });
model.setToExpire(); model.setToExpire();
if (!existing) {
const { messagesAdded } = window.reduxActions.conversations;
const isNewMessage = true;
messagesAdded(
this.id,
[model.getReduxData()],
isNewMessage,
document.hasFocus()
);
}
return model; return model;
}, },
@ -310,7 +320,12 @@
const { format } = PhoneNumber; const { format } = PhoneNumber;
const regionCode = storage.get('regionCode'); const regionCode = storage.get('regionCode');
const color = this.getColor(); const color = this.getColor();
const typingKeys = Object.keys(this.contactTypingTimers || {});
const typingValues = _.values(this.contactTypingTimers || {});
const typingMostRecent = _.first(_.sortBy(typingValues, 'timestamp'));
const typingContact = typingMostRecent
? ConversationController.getOrCreate(typingMostRecent.sender, 'private')
: null;
const result = { const result = {
id: this.id, id: this.id,
@ -321,7 +336,7 @@
color, color,
type: this.isPrivate() ? 'direct' : 'group', type: this.isPrivate() ? 'direct' : 'group',
isMe: this.isMe(), isMe: this.isMe(),
isTyping: typingKeys.length > 0, typingContact: typingContact ? typingContact.format() : null,
lastUpdated: this.get('timestamp'), lastUpdated: this.get('timestamp'),
name: this.getName(), name: this.getName(),
profileName: this.getProfileName(), profileName: this.getProfileName(),
@ -894,6 +909,9 @@
sendMessage(body, attachments, quote, preview, sticker) { sendMessage(body, attachments, quote, preview, sticker) {
this.clearTypingTimers(); this.clearTypingTimers();
const { clearUnreadMetrics } = window.reduxActions.conversations;
clearUnreadMetrics(this.id);
const destination = this.id; const destination = this.id;
const expireTimer = this.get('expireTimer'); const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients(); const recipients = this.getRecipients();
@ -1202,7 +1220,7 @@
return; return;
} }
const messages = await window.Signal.Data.getMessagesByConversation( const messages = await window.Signal.Data.getOlderMessagesByConversation(
this.id, this.id,
{ limit: 1, MessageCollection: Whisper.MessageCollection } { limit: 1, MessageCollection: Whisper.MessageCollection }
); );
@ -1310,7 +1328,7 @@
model.set({ id }); model.set({ id });
const message = MessageController.register(id, model); const message = MessageController.register(id, model);
this.messageCollection.add(message); this.addSingleMessage(message);
// if change was made remotely, don't send it to the number/group // if change was made remotely, don't send it to the number/group
if (receivedAt) { if (receivedAt) {
@ -1373,7 +1391,7 @@
async endSession() { async endSession() {
if (this.isPrivate()) { if (this.isPrivate()) {
const now = Date.now(); const now = Date.now();
const message = this.messageCollection.add({ const model = new Whisper.Message({
conversationId: this.id, conversationId: this.id,
type: 'outgoing', type: 'outgoing',
sent_at: now, sent_at: now,
@ -1383,10 +1401,13 @@
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
}); });
const id = await window.Signal.Data.saveMessage(message.attributes, { const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message, Message: Whisper.Message,
}); });
message.set({ id }); model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions(); const options = this.getSendOptions();
message.send( message.send(
@ -1407,7 +1428,7 @@
groupUpdate = this.pick(['name', 'avatar', 'members']); groupUpdate = this.pick(['name', 'avatar', 'members']);
} }
const now = Date.now(); const now = Date.now();
const message = this.messageCollection.add({ const model = new Whisper.Message({
conversationId: this.id, conversationId: this.id,
type: 'outgoing', type: 'outgoing',
sent_at: now, sent_at: now,
@ -1415,10 +1436,14 @@
group_update: groupUpdate, group_update: groupUpdate,
}); });
const id = await window.Signal.Data.saveMessage(message.attributes, { const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message, Message: Whisper.Message,
}); });
message.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions(); const options = this.getSendOptions();
message.send( message.send(
@ -1443,7 +1468,7 @@
Conversation: Whisper.Conversation, Conversation: Whisper.Conversation,
}); });
const message = this.messageCollection.add({ const model = new Whisper.Message({
group_update: { left: 'You' }, group_update: { left: 'You' },
conversationId: this.id, conversationId: this.id,
type: 'outgoing', type: 'outgoing',
@ -1451,10 +1476,13 @@
received_at: now, received_at: now,
}); });
const id = await window.Signal.Data.saveMessage(message.attributes, { const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message, Message: Whisper.Message,
}); });
message.set({ id }); model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions(); const options = this.getSendOptions();
message.send( message.send(
@ -1830,57 +1858,6 @@
this.set({ accessKey }); this.set({ accessKey });
}, },
async upgradeMessages(messages) {
for (let max = messages.length, i = 0; i < max; i += 1) {
const message = messages.at(i);
const { attributes } = message;
const { schemaVersion } = attributes;
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
const upgradedMessage = await upgradeMessageSchema(attributes);
message.set(upgradedMessage);
// eslint-disable-next-line no-await-in-loop
await window.Signal.Data.saveMessage(upgradedMessage, {
Message: Whisper.Message,
});
}
}
},
async fetchMessages() {
if (!this.id) {
throw new Error('This conversation has no id!');
}
if (this.inProgressFetch) {
window.log.warn('Attempting to start a parallel fetchMessages() call');
return;
}
this.inProgressFetch = this.messageCollection.fetchConversation(
this.id,
undefined,
this.get('unreadCount')
);
await this.inProgressFetch;
try {
// We are now doing the work to upgrade messages before considering the load from
// the database complete. Note that we do save messages back, so it is a
// one-time hit. We do this so we have guarantees about message structure.
await this.upgradeMessages(this.messageCollection);
} catch (error) {
window.log.error(
'fetchMessages: failed to upgrade messages',
Errors.toLogFormat(error)
);
}
this.inProgressFetch = null;
},
hasMember(number) { hasMember(number) {
return _.contains(this.get('members'), number); return _.contains(this.get('members'), number);
}, },
@ -1908,10 +1885,6 @@
}, },
async destroyMessages() { async destroyMessages() {
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
MessageCollection: Whisper.MessageCollection,
});
this.messageCollection.reset([]); this.messageCollection.reset([]);
this.set({ this.set({
@ -1922,6 +1895,10 @@
await window.Signal.Data.updateConversation(this.id, this.attributes, { await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation, Conversation: Whisper.Conversation,
}); });
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
MessageCollection: Whisper.MessageCollection,
});
}, },
getName() { getName() {
@ -2102,10 +2079,6 @@
clearTimeout(record.timer); clearTimeout(record.timer);
} }
// Note: We trigger two events because:
// 'typing-update' is a surgical update ConversationView does for in-convo bubble
// 'change' causes a re-render of this conversation's list item in the left pane
if (isTyping) { if (isTyping) {
this.contactTypingTimers[identifier] = this.contactTypingTimers[ this.contactTypingTimers[identifier] = this.contactTypingTimers[
identifier identifier
@ -2121,14 +2094,12 @@
); );
if (!record) { if (!record) {
// User was not previously typing before. State change! // User was not previously typing before. State change!
this.trigger('typing-update');
this.trigger('change', this); this.trigger('change', this);
} }
} else { } else {
delete this.contactTypingTimers[identifier]; delete this.contactTypingTimers[identifier];
if (record) { if (record) {
// User was previously typing, and is no longer. State change! // User was previously typing, and is no longer. State change!
this.trigger('typing-update');
this.trigger('change', this); this.trigger('change', this);
} }
} }
@ -2143,7 +2114,6 @@
delete this.contactTypingTimers[identifier]; delete this.contactTypingTimers[identifier];
// User was previously typing, but timed out or we received message. State change! // User was previously typing, but timed out or we received message. State change!
this.trigger('typing-update');
this.trigger('change', this); this.trigger('change', this);
} }
}, },
@ -2155,17 +2125,6 @@
comparator(m) { comparator(m) {
return -m.get('timestamp'); return -m.get('timestamp');
}, },
async destroyAll() {
await Promise.all(
this.models.map(conversation =>
window.Signal.Data.removeConversation(conversation.id, {
Conversation: Whisper.Conversation,
})
)
);
this.reset([]);
},
}); });
Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' '); Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');

View file

@ -100,69 +100,67 @@
this.on('expired', this.onExpired); this.on('expired', this.onExpired);
this.setToExpire(); this.setToExpire();
this.on('change', this.generateProps); this.on('change', this.notifyRedux);
},
const applicableConversationChanges = notifyRedux() {
'change:color change:name change:number change:profileName change:profileAvatar'; const { messageChanged } = window.reduxActions.conversations;
const conversation = this.getConversation(); if (messageChanged) {
const fromContact = this.getIncomingContact(); const conversationId = this.get('conversationId');
// Note: The clone is important for triggering a re-run of selectors
this.listenTo( messageChanged(this.id, conversationId, _.clone(this.attributes));
conversation,
applicableConversationChanges,
this.generateProps
);
if (fromContact) {
this.listenTo(
fromContact,
applicableConversationChanges,
this.generateProps
);
} }
},
this.generateProps(); getReduxData() {
const contact = this.getPropsForEmbeddedContact();
return {
...this.attributes,
// We need this in the reducer to detect if the message's height has changed
hasSignalAccount: contact ? Boolean(contact.signalAccount) : null,
};
}, },
// Top-level prop generation for the message bubble // Top-level prop generation for the message bubble
generateProps() { getPropsForBubble() {
if (this.isUnsupportedMessage()) { if (this.isUnsupportedMessage()) {
this.props = { return {
type: 'unsupportedMessage', type: 'unsupportedMessage',
data: this.getPropsForUnsupportedMessage(), data: this.getPropsForUnsupportedMessage(),
}; };
} else if (this.isExpirationTimerUpdate()) { } else if (this.isExpirationTimerUpdate()) {
this.props = { return {
type: 'timerNotification', type: 'timerNotification',
data: this.getPropsForTimerNotification(), data: this.getPropsForTimerNotification(),
}; };
} else if (this.isKeyChange()) { } else if (this.isKeyChange()) {
this.props = { return {
type: 'safetyNumberNotification', type: 'safetyNumberNotification',
data: this.getPropsForSafetyNumberNotification(), data: this.getPropsForSafetyNumberNotification(),
}; };
} else if (this.isVerifiedChange()) { } else if (this.isVerifiedChange()) {
this.props = { return {
type: 'verificationNotification', type: 'verificationNotification',
data: this.getPropsForVerificationNotification(), data: this.getPropsForVerificationNotification(),
}; };
} else if (this.isGroupUpdate()) { } else if (this.isGroupUpdate()) {
this.props = { return {
type: 'groupNotification', type: 'groupNotification',
data: this.getPropsForGroupNotification(), data: this.getPropsForGroupNotification(),
}; };
} else if (this.isEndSession()) { } else if (this.isEndSession()) {
this.props = { return {
type: 'resetSessionNotification', type: 'resetSessionNotification',
data: this.getPropsForResetSessionNotification(), data: this.getPropsForResetSessionNotification(),
}; };
} else {
this.propsForSearchResult = this.getPropsForSearchResult();
this.props = {
type: 'message',
data: this.getPropsForMessage(),
};
} }
return {
type: 'message',
data: this.getPropsForMessage(),
};
}, },
// Other top-level prop-generation // Other top-level prop-generation
@ -269,6 +267,21 @@
disableScroll: true, disableScroll: true,
// To ensure that group avatar doesn't show up // To ensure that group avatar doesn't show up
conversationType: 'direct', conversationType: 'direct',
downloadNewVersion: () => {
this.trigger('download-new-version');
},
deleteMessage: messageId => {
this.trigger('delete', messageId);
},
showVisualAttachment: options => {
this.trigger('show-visual-attachment', options);
},
displayTapToViewMessage: messageId => {
this.trigger('display-tap-to-view-message', messageId);
},
openLink: url => {
this.trigger('navigate-to', url);
},
}, },
errors, errors,
contacts: sortedContacts, contacts: sortedContacts,
@ -290,7 +303,7 @@
const flag = const flag =
textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
return !!(this.get('flags') & flag); return Boolean(this.get('flags') & flag);
}, },
isKeyChange() { isKeyChange() {
return this.get('type') === 'keychange'; return this.get('type') === 'keychange';
@ -353,12 +366,10 @@
const conversation = this.getConversation(); const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate(); const isGroup = conversation && !conversation.isPrivate();
const phoneNumber = this.get('key_changed'); const phoneNumber = this.get('key_changed');
const showIdentity = id => this.trigger('show-identity', id);
return { return {
isGroup, isGroup,
contact: this.findAndFormatContact(phoneNumber), contact: this.findAndFormatContact(phoneNumber),
showIdentity,
}; };
}, },
getPropsForVerificationNotification() { getPropsForVerificationNotification() {
@ -498,28 +509,6 @@
isTapToViewExpired: isTapToView && this.get('isErased'), isTapToViewExpired: isTapToView && this.get('isErased'),
isTapToViewError: isTapToViewError:
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'), isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
replyToMessage: id => this.trigger('reply', id),
retrySend: id => this.trigger('retry', id),
deleteMessage: id => this.trigger('delete', id),
showMessageDetail: id => this.trigger('show-message-detail', id),
openConversation: conversationId =>
this.trigger('open-conversation', conversationId),
showContactDetail: contactOptions =>
this.trigger('show-contact-detail', contactOptions),
showVisualAttachment: lightboxOptions =>
this.trigger('show-lightbox', lightboxOptions),
downloadAttachment: downloadOptions =>
this.trigger('download', downloadOptions),
displayTapToViewMessage: messageId =>
this.trigger('display-tap-to-view-message', messageId),
openLink: url => this.trigger('navigate-to', url),
downloadNewVersion: () => this.trigger('download-new-version'),
scrollToMessage: scrollOptions =>
this.trigger('scroll-to-message', scrollOptions),
}; };
}, },
@ -692,6 +681,7 @@
authorName, authorName,
authorColor, authorColor,
referencedMessageNotFound, referencedMessageNotFound,
onClick: () => this.trigger('scroll-to-message'),
}; };
}, },
getStatus(number) { getStatus(number) {
@ -851,6 +841,8 @@
this.cleanup(); this.cleanup();
}, },
async cleanup() { async cleanup() {
const { messageDeleted } = window.reduxActions.conversations;
messageDeleted(this.id, this.get('conversationId'));
MessageController.unregister(this.id); MessageController.unregister(this.id);
this.unload(); this.unload();
await this.deleteData(); await this.deleteData();
@ -2193,74 +2185,5 @@
return (left.get('received_at') || 0) - (right.get('received_at') || 0); return (left.get('received_at') || 0) - (right.get('received_at') || 0);
}, },
initialize(models, options) {
if (options) {
this.conversation = options.conversation;
}
},
async destroyAll() {
await Promise.all(
this.models.map(message =>
window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message,
})
)
);
this.reset([]);
},
getLoadedUnreadCount() {
return this.reduce((total, model) => {
const unread = model.get('unread') && model.isIncoming();
return total + (unread ? 1 : 0);
}, 0);
},
async fetchConversation(conversationId, limit = 100, unreadCount = 0) {
const startingLoadedUnread =
unreadCount > 0 ? this.getLoadedUnreadCount() : 0;
// We look for older messages if we've fetched once already
const receivedAt =
this.length === 0 ? Number.MAX_VALUE : this.at(0).get('received_at');
const messages = await window.Signal.Data.getMessagesByConversation(
conversationId,
{
limit,
receivedAt,
MessageCollection: Whisper.MessageCollection,
}
);
const models = messages
.filter(message => Boolean(message.id))
.map(message => MessageController.register(message.id, message));
const eliminated = messages.length - models.length;
if (eliminated > 0) {
window.log.warn(
`fetchConversation: Eliminated ${eliminated} messages without an id`
);
}
this.add(models);
if (unreadCount <= 0) {
return;
}
const loadedUnread = this.getLoadedUnreadCount();
if (loadedUnread >= unreadCount) {
return;
}
if (startingLoadedUnread === loadedUnread) {
// that fetch didn't get us any more unread. stop fetching more.
return;
}
window.log.info(
'fetchConversation: doing another fetch to get all unread'
);
await this.fetchConversation(conversationId, limit, unreadCount);
},
}); });
})(); })();

View file

@ -696,7 +696,7 @@ async function exportConversation(conversation, options = {}) {
while (!complete) { while (!complete) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const collection = await window.Signal.Data.getMessagesByConversation( const collection = await window.Signal.Data.getOlderMessagesByConversation(
conversation.id, conversation.id,
{ {
limit: CHUNK_SIZE, limit: CHUNK_SIZE,

View file

@ -121,9 +121,11 @@ module.exports = {
getExpiredMessages, getExpiredMessages,
getOutgoingWithoutExpiresAt, getOutgoingWithoutExpiresAt,
getNextExpiringMessage, getNextExpiringMessage,
getMessagesByConversation,
getNextTapToViewMessageToAgeOut, getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase, getTapToViewMessagesNeedingErase,
getOlderMessagesByConversation,
getNewerMessagesByConversation,
getMessageMetricsForConversation,
getUnprocessedCount, getUnprocessedCount,
getAllUnprocessed, getAllUnprocessed,
@ -779,17 +781,40 @@ async function getUnreadByConversation(conversationId, { MessageCollection }) {
return new MessageCollection(messages); return new MessageCollection(messages);
} }
async function getMessagesByConversation( async function getOlderMessagesByConversation(
conversationId, conversationId,
{ limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection } { limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection }
) { ) {
const messages = await channels.getMessagesByConversation(conversationId, { const messages = await channels.getOlderMessagesByConversation(
limit, conversationId,
receivedAt, {
}); limit,
receivedAt,
}
);
return new MessageCollection(messages); return new MessageCollection(messages);
} }
async function getNewerMessagesByConversation(
conversationId,
{ limit = 100, receivedAt = 0, MessageCollection }
) {
const messages = await channels.getNewerMessagesByConversation(
conversationId,
{
limit,
receivedAt,
}
);
return new MessageCollection(messages);
}
async function getMessageMetricsForConversation(conversationId) {
const result = await channels.getMessageMetricsForConversation(
conversationId
);
return result;
}
async function removeAllMessagesInConversation( async function removeAllMessagesInConversation(
conversationId, conversationId,
@ -800,7 +825,7 @@ async function removeAllMessagesInConversation(
// Yes, we really want the await in the loop. We're deleting 100 at a // Yes, we really want the await in the loop. We're deleting 100 at a
// time so we don't use too much memory. // time so we don't use too much memory.
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
messages = await getMessagesByConversation(conversationId, { messages = await getOlderMessagesByConversation(conversationId, {
limit: 100, limit: 100,
MessageCollection, MessageCollection,
}); });

View file

@ -28,51 +28,25 @@ const {
ContactDetail, ContactDetail,
} = require('../../ts/components/conversation/ContactDetail'); } = require('../../ts/components/conversation/ContactDetail');
const { ContactListItem } = require('../../ts/components/ContactListItem'); const { ContactListItem } = require('../../ts/components/ContactListItem');
const { ContactName } = require('../../ts/components/conversation/ContactName');
const { const {
ConversationHeader, ConversationHeader,
} = require('../../ts/components/conversation/ConversationHeader'); } = require('../../ts/components/conversation/ConversationHeader');
const {
EmbeddedContact,
} = require('../../ts/components/conversation/EmbeddedContact');
const { Emojify } = require('../../ts/components/conversation/Emojify'); const { Emojify } = require('../../ts/components/conversation/Emojify');
const {
GroupNotification,
} = require('../../ts/components/conversation/GroupNotification');
const { Lightbox } = require('../../ts/components/Lightbox'); const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery'); const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const { const {
MediaGallery, MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery'); } = require('../../ts/components/conversation/media-gallery/MediaGallery');
const { Message } = require('../../ts/components/conversation/Message');
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
const { const {
MessageDetail, MessageDetail,
} = require('../../ts/components/conversation/MessageDetail'); } = require('../../ts/components/conversation/MessageDetail');
const { Quote } = require('../../ts/components/conversation/Quote'); const { Quote } = require('../../ts/components/conversation/Quote');
const {
ResetSessionNotification,
} = require('../../ts/components/conversation/ResetSessionNotification');
const {
SafetyNumberNotification,
} = require('../../ts/components/conversation/SafetyNumberNotification');
const { const {
StagedLinkPreview, StagedLinkPreview,
} = require('../../ts/components/conversation/StagedLinkPreview'); } = require('../../ts/components/conversation/StagedLinkPreview');
const {
TimerNotification,
} = require('../../ts/components/conversation/TimerNotification');
const {
TypingBubble,
} = require('../../ts/components/conversation/TypingBubble');
const {
UnsupportedMessage,
} = require('../../ts/components/conversation/UnsupportedMessage');
const {
VerificationNotification,
} = require('../../ts/components/conversation/VerificationNotification');
// State // State
const { createTimeline } = require('../../ts/state/roots/createTimeline');
const { const {
createCompositionArea, createCompositionArea,
} = require('../../ts/state/roots/createCompositionArea'); } = require('../../ts/state/roots/createCompositionArea');
@ -264,33 +238,23 @@ exports.setup = (options = {}) => {
CaptionEditor, CaptionEditor,
ContactDetail, ContactDetail,
ContactListItem, ContactListItem,
ContactName,
ConversationHeader, ConversationHeader,
EmbeddedContact,
Emojify, Emojify,
GroupNotification,
Lightbox, Lightbox,
LightboxGallery, LightboxGallery,
MediaGallery, MediaGallery,
Message,
MessageBody,
MessageDetail, MessageDetail,
Quote, Quote,
ResetSessionNotification,
SafetyNumberNotification,
StagedLinkPreview, StagedLinkPreview,
TimerNotification,
Types: { Types: {
Message: MediaGalleryMessage, Message: MediaGalleryMessage,
}, },
TypingBubble,
UnsupportedMessage,
VerificationNotification,
}; };
const Roots = { const Roots = {
createCompositionArea, createCompositionArea,
createLeftPane, createLeftPane,
createTimeline,
createStickerManager, createStickerManager,
createStickerPreviewModal, createStickerPreviewModal,
}; };

View file

@ -152,7 +152,7 @@
silent: !status.shouldPlayNotificationSound, silent: !status.shouldPlayNotificationSound,
}); });
this.lastNotification.onclick = () => this.lastNotification.onclick = () =>
this.trigger('click', last.conversationId, last.id); this.trigger('click', last.conversationId, last.messageId);
// We continue to build up more and more messages for our notifications // We continue to build up more and more messages for our notifications
// until the user comes back to our app or closes the app. Then well // until the user comes back to our app or closes the app. Then well

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
/* global Backbone, Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.GroupUpdateView = Backbone.View.extend({
tagName: 'div',
className: 'group-update',
render() {
// TODO l10n
if (this.model.left) {
this.$el.text(`${this.model.left} left the group`);
return this;
}
const messages = ['Updated the group.'];
if (this.model.name) {
messages.push(`Title is now '${this.model.name}'.`);
}
if (this.model.joined) {
messages.push(`${this.model.joined.join(', ')} joined the group`);
}
this.$el.text(messages.join(' '));
return this;
},
});
})();

View file

@ -22,31 +22,28 @@
Whisper.ConversationStack = Whisper.View.extend({ Whisper.ConversationStack = Whisper.View.extend({
className: 'conversation-stack', className: 'conversation-stack',
lastConversation: null, lastConversation: null,
open(conversation) { open(conversation, messageId) {
const id = `conversation-${conversation.cid}`; const id = `conversation-${conversation.cid}`;
if (id !== this.el.firstChild.id) { if (id !== this.el.lastChild.id) {
this.$el const view = new Whisper.ConversationView({
.first() model: conversation,
.find('video, audio') window: this.model.window,
.each(function pauseMedia() { });
this.pause(); view.$el.appendTo(this.el);
});
let $el = this.$(`#${id}`); if (this.lastConversation) {
if ($el === null || $el.length === 0) { this.lastConversation.trigger(
const view = new Whisper.ConversationView({ 'unload',
model: conversation, 'opened another conversation'
window: this.model.window, );
});
// eslint-disable-next-line prefer-destructuring
$el = view.$el;
} }
$el.prependTo(this.el);
this.lastConversation = conversation;
conversation.trigger('opened', messageId);
} else if (messageId) {
conversation.trigger('scroll-to-message', messageId);
} }
conversation.trigger('opened');
if (this.lastConversation) {
this.lastConversation.trigger('backgrounded');
}
this.lastConversation = conversation;
// Make sure poppers are positioned properly // Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}, },
@ -122,11 +119,10 @@
}, },
setupLeftPane() { setupLeftPane() {
this.leftPaneView = new Whisper.ReactWrapperView({ this.leftPaneView = new Whisper.ReactWrapperView({
JSX: Signal.State.Roots.createLeftPane(window.reduxStore),
className: 'left-pane-wrapper', className: 'left-pane-wrapper',
JSX: Signal.State.Roots.createLeftPane(window.reduxStore),
}); });
// Finally, add it to the DOM
this.$('.left-pane-placeholder').append(this.leftPaneView.el); this.$('.left-pane-placeholder').append(this.leftPaneView.el);
}, },
startConnectionListener() { startConnectionListener() {
@ -194,7 +190,7 @@
openConversationExternal(id, messageId); openConversationExternal(id, messageId);
} }
this.conversation_stack.open(conversation); this.conversation_stack.open(conversation, messageId);
this.focusConversation(); this.focusConversation();
}, },
closeRecording(e) { closeRecording(e) {

View file

@ -1,36 +0,0 @@
/* global Whisper, i18n */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.LastSeenIndicatorView = Whisper.View.extend({
className: 'module-last-seen-indicator',
templateName: 'last-seen-indicator-view',
initialize(options = {}) {
this.count = options.count || 0;
},
increment(count) {
this.count += count;
this.render();
},
getCount() {
return this.count;
},
render_attributes() {
const unreadMessages =
this.count === 1
? i18n('unreadMessage')
: i18n('unreadMessages', [this.count]);
return {
unreadMessages,
};
},
});
})();

View file

@ -1,143 +0,0 @@
/* global Whisper, Backbone, _, $ */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.MessageListView = Backbone.View.extend({
tagName: 'ul',
className: 'message-list',
template: $('#message-list').html(),
itemView: Whisper.MessageView,
events: {
scroll: 'onScroll',
},
// Here we reimplement Whisper.ListView so we can override addAll
render() {
this.addAll();
return this;
},
// The key is that we don't erase all inner HTML, we re-render our template.
// And then we keep a reference to .messages
addAll() {
Whisper.View.prototype.render.call(this);
this.$messages = this.$('.messages');
this.collection.each(this.addOne, this);
},
initialize() {
this.listenTo(this.collection, 'add', this.addOne);
this.listenTo(this.collection, 'reset', this.addAll);
this.render();
this.triggerLazyScroll = _.debounce(() => {
this.$el.trigger('lazyScroll');
}, 500);
},
onScroll() {
this.measureScrollPosition();
if (this.$el.scrollTop() === 0) {
this.$el.trigger('loadMore');
}
if (this.atBottom()) {
this.$el.trigger('atBottom');
} else if (this.bottomOffset > this.outerHeight) {
this.$el.trigger('farFromBottom');
}
this.triggerLazyScroll();
},
atBottom() {
return this.bottomOffset < 30;
},
measureScrollPosition() {
if (this.el.scrollHeight === 0) {
// hidden
return;
}
this.outerHeight = this.$el.outerHeight();
this.scrollPosition = this.$el.scrollTop() + this.outerHeight;
this.scrollHeight = this.el.scrollHeight;
this.bottomOffset = this.scrollHeight - this.scrollPosition;
},
resetScrollPosition() {
this.$el.scrollTop(this.scrollPosition - this.$el.outerHeight());
},
restoreBottomOffset() {
if (_.isNumber(this.bottomOffset)) {
// + 10 is necessary to account for padding
const height = this.$el.height() + 10;
const topOfBottomScreen = this.el.scrollHeight - height;
this.$el.scrollTop(topOfBottomScreen - this.bottomOffset);
}
},
scrollToBottomIfNeeded() {
// This is counter-intuitive. Our current bottomOffset is reflective of what
// we last measured, not necessarily the current state. And this is called
// after we just made a change to the DOM: inserting a message, or an image
// finished loading. So if we were near the bottom before, we _need_ to be
// at the bottom again. So we scroll to the bottom.
if (this.atBottom()) {
this.scrollToBottom();
}
},
scrollToBottom() {
this.$el.scrollTop(this.el.scrollHeight);
this.measureScrollPosition();
},
addOne(model) {
// eslint-disable-next-line new-cap
const view = new this.itemView({ model }).render();
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
const index = this.collection.indexOf(model);
this.measureScrollPosition();
if (model.get('unread') && !this.atBottom()) {
this.$el.trigger('newOffscreenMessage');
}
if (index === this.collection.length - 1) {
// add to the bottom.
this.$messages.append(view.el);
} else if (index === 0) {
// add to top
this.$messages.prepend(view.el);
} else {
// insert
const next = this.$(`#${this.collection.at(index + 1).id}`);
const prev = this.$(`#${this.collection.at(index - 1).id}`);
if (next.length > 0) {
view.$el.insertBefore(next);
} else if (prev.length > 0) {
view.$el.insertAfter(prev);
} else {
// scan for the right spot
const elements = this.$messages.children();
if (elements.length > 0) {
for (let i = 0; i < elements.length; i += 1) {
const m = this.collection.get(elements[i].id);
const mIndex = this.collection.indexOf(m);
if (mIndex > index) {
view.$el.insertBefore(elements[i]);
break;
}
}
} else {
this.$messages.append(view.el);
}
}
}
this.scrollToBottomIfNeeded();
},
});
})();

View file

@ -1,149 +0,0 @@
/* global Whisper: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.MessageView = Whisper.View.extend({
tagName: 'li',
id() {
return this.model.id;
},
initialize() {
this.listenTo(this.model, 'change', this.onChange);
this.listenTo(this.model, 'destroy', this.onDestroy);
this.listenTo(this.model, 'unload', this.onUnload);
this.listenTo(this.model, 'expired', this.onExpired);
this.updateHiddenSticker();
},
updateHiddenSticker() {
const sticker = this.model.get('sticker');
this.isHiddenSticker = sticker && (!sticker.data || !sticker.data.path);
},
onChange() {
this.addId();
},
addId() {
// The ID is important for other items inserting themselves into the DOM. Because
// of ReactWrapperView and this view, there are two layers of DOM elements
// between the parent and the elements returned by the React component, so this is
// necessary.
const { id } = this.model;
this.$el.attr('id', id);
},
onExpired() {
setTimeout(() => this.onUnload(), 1000);
},
onUnload() {
if (this.childView) {
this.childView.remove();
}
this.remove();
},
onDestroy() {
this.onUnload();
},
getRenderInfo() {
const { Components } = window.Signal;
const { type, data: props } = this.model.props;
if (type === 'unsupportedMessage') {
return {
Component: Components.UnsupportedMessage,
props,
};
} else if (type === 'timerNotification') {
return {
Component: Components.TimerNotification,
props,
};
} else if (type === 'safetyNumberNotification') {
return {
Component: Components.SafetyNumberNotification,
props,
};
} else if (type === 'verificationNotification') {
return {
Component: Components.VerificationNotification,
props,
};
} else if (type === 'groupNotification') {
return {
Component: Components.GroupNotification,
props,
};
} else if (type === 'resetSessionNotification') {
return {
Component: Components.ResetSessionNotification,
props,
};
}
return {
Component: Components.Message,
props,
};
},
render() {
this.addId();
if (this.childView) {
this.childView.remove();
this.childView = null;
}
const { Component, props } = this.getRenderInfo();
this.childView = new Whisper.ReactWrapperView({
className: 'message-wrapper',
Component,
props,
});
const update = () => {
const info = this.getRenderInfo();
this.childView.update(info.props, () => {
if (!this.isHiddenSticker) {
return;
}
this.updateHiddenSticker();
if (!this.isHiddenSticker) {
this.model.trigger('height-changed');
}
});
};
this.listenTo(this.model, 'change', update);
this.listenTo(this.model, 'expired', update);
const applicableConversationChanges =
'change:color change:name change:number change:profileName change:profileAvatar';
this.conversation = this.model.getConversation();
this.listenTo(this.conversation, applicableConversationChanges, update);
this.fromContact = this.model.getIncomingContact();
if (this.fromContact) {
this.listenTo(this.fromContact, applicableConversationChanges, update);
}
this.quotedContact = this.model.getQuoteContact();
if (this.quotedContact) {
this.listenTo(
this.quotedContact,
applicableConversationChanges,
update
);
}
this.$el.append(this.childView.el);
return this;
},
});
})();

View file

@ -1,39 +0,0 @@
/* global Whisper, i18n */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.ScrollDownButtonView = Whisper.View.extend({
className: 'module-scroll-down',
templateName: 'scroll-down-button-view',
initialize(options = {}) {
this.count = options.count || 0;
},
increment(count = 0) {
this.count += count;
this.render();
},
render_attributes() {
const buttonClass =
this.count > 0 ? 'module-scroll-down__button--new-messages' : '';
let moreBelow = i18n('scrollDown');
if (this.count > 1) {
moreBelow = i18n('messagesBelow');
} else if (this.count === 1) {
moreBelow = i18n('messageBelow');
}
return {
buttonClass,
moreBelow,
};
},
});
})();

View file

@ -12,7 +12,7 @@
}, },
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider", "postinstall": "patch-package && electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
"start": "electron .", "start": "electron .",
"grunt": "grunt", "grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build", "icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
@ -169,6 +169,7 @@
"mocha-testcheck": "1.0.0-rc.0", "mocha-testcheck": "1.0.0-rc.0",
"node-sass-import-once": "1.2.0", "node-sass-import-once": "1.2.0",
"nyc": "11.4.1", "nyc": "11.4.1",
"patch-package": "6.1.2",
"prettier": "1.12.0", "prettier": "1.12.0",
"react-docgen-typescript": "1.2.6", "react-docgen-typescript": "1.2.6",
"react-styleguidist": "7.0.1", "react-styleguidist": "7.0.1",

View file

@ -0,0 +1,357 @@
diff --git a/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js b/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js
index d9716a0..e7a9f9f 100644
--- a/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js
+++ b/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js
@@ -166,13 +166,19 @@ var CellMeasurer = function (_React$PureComponent) {
height = _getCellMeasurements2.height,
width = _getCellMeasurements2.width;
+
cache.set(rowIndex, columnIndex, width, height);
// If size has changed, let Grid know to re-render.
if (parent && typeof parent.invalidateCellSizeAfterRender === 'function') {
+ const heightChange = height - cache.defaultHeight;
+ const widthChange = width - cache.defaultWidth;
+
parent.invalidateCellSizeAfterRender({
columnIndex: columnIndex,
- rowIndex: rowIndex
+ rowIndex: rowIndex,
+ heightChange: heightChange,
+ widthChange: widthChange,
});
}
}
diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js
index e1b959a..1e3a269 100644
--- a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js
+++ b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js
@@ -132,6 +132,9 @@ var Grid = function (_React$PureComponent) {
_this._renderedRowStopIndex = 0;
_this._styleCache = {};
_this._cellCache = {};
+ _this._cellUpdates = [];
+ this._hasScrolledToColumnTarget = false;
+ this._hasScrolledToRowTarget = false;
_this._debounceScrollEndedCallback = function () {
_this._disablePointerEventsTimeoutId = null;
@@ -345,7 +348,11 @@ var Grid = function (_React$PureComponent) {
scrollLeft: scrollLeft,
scrollTop: scrollTop,
totalColumnsWidth: totalColumnsWidth,
- totalRowsHeight: totalRowsHeight
+ totalRowsHeight: totalRowsHeight,
+ scrollToColumn: this.props.scrollToColumn,
+ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget,
+ scrollToRow: this.props.scrollToRow,
+ _hasScrolledToRowTarget: this._hasScrolledToRowTarget,
});
}
@@ -362,6 +369,9 @@ var Grid = function (_React$PureComponent) {
value: function invalidateCellSizeAfterRender(_ref3) {
var columnIndex = _ref3.columnIndex,
rowIndex = _ref3.rowIndex;
+ if (!this._disableCellUpdates) {
+ this._cellUpdates.push(_ref3);
+ }
this._deferredInvalidateColumnIndex = typeof this._deferredInvalidateColumnIndex === 'number' ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) : columnIndex;
this._deferredInvalidateRowIndex = typeof this._deferredInvalidateRowIndex === 'number' ? Math.min(this._deferredInvalidateRowIndex, rowIndex) : rowIndex;
@@ -381,8 +391,12 @@ var Grid = function (_React$PureComponent) {
rowCount = _props2.rowCount;
var instanceProps = this.state.instanceProps;
- instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1);
- instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1);
+ if (columnCount > 0) {
+ instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1);
+ }
+ if (rowCount > 0) {
+ instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1);
+ }
}
/**
@@ -415,6 +429,15 @@ var Grid = function (_React$PureComponent) {
this._recomputeScrollLeftFlag = scrollToColumn >= 0 && (this.state.scrollDirectionHorizontal === _defaultOverscanIndicesGetter.SCROLL_DIRECTION_FORWARD ? columnIndex <= scrollToColumn : columnIndex >= scrollToColumn);
this._recomputeScrollTopFlag = scrollToRow >= 0 && (this.state.scrollDirectionVertical === _defaultOverscanIndicesGetter.SCROLL_DIRECTION_FORWARD ? rowIndex <= scrollToRow : rowIndex >= scrollToRow);
+ // Global notification that we should retry our scroll to props-requested indices
+ this._hasScrolledToColumnTarget = false;
+ this._hasScrolledToRowTarget = false;
+
+ // Disable cell updates for global reset
+ if (rowIndex >= this.props.rowCount - 1 || columnIndex >= this.props.columnCount - 1) {
+ this._disableCellUpdates = true;
+ }
+
// Clear cell cache in case we are scrolling;
// Invalid row heights likely mean invalid cached content as well.
this._styleCache = {};
@@ -526,7 +549,11 @@ var Grid = function (_React$PureComponent) {
scrollLeft: scrollLeft || 0,
scrollTop: scrollTop || 0,
totalColumnsWidth: instanceProps.columnSizeAndPositionManager.getTotalSize(),
- totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize()
+ totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize(),
+ scrollToColumn: scrollToColumn,
+ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget,
+ scrollToRow: scrollToRow,
+ _hasScrolledToRowTarget: this._hasScrolledToRowTarget,
});
this._maybeCallOnScrollbarPresenceChange();
@@ -584,6 +611,51 @@ var Grid = function (_React$PureComponent) {
}
}
+ if (scrollToColumn >= 0) {
+ const scrollRight = scrollLeft + width;
+ const targetColumn = instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(scrollToColumn);
+
+ let isVisible = false;
+ if (targetColumn.size <= width) {
+ const targetColumnRight = targetColumn.offset + targetColumn.size;
+ isVisible = (targetColumn.offset >= scrollLeft && targetColumnRight <= scrollRight);
+ } else {
+ isVisible = (targetColumn.offset >= scrollLeft && targetColumn.offset <= scrollRight);
+ }
+
+ if (isVisible) {
+ const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize();
+ const maxScroll = totalColumnsWidth - width;
+ this._hasScrolledToColumnTarget = (scrollLeft >= maxScroll || targetColumn.offset === scrollLeft);
+ } else if (scrollToColumn !== prevProps.scrollToColumn) {
+ this._hasScrolledToColumnTarget = false;
+ }
+ } else {
+ this._hasScrolledToColumnTarget = false;
+ }
+ if (scrollToRow >= 0) {
+ const scrollBottom = scrollTop + height;
+ const targetRow = instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(scrollToRow);
+
+ let isVisible = false;
+ if (targetRow.size <= height) {
+ const targetRowBottom = targetRow.offset + targetRow.size;
+ isVisible = (targetRow.offset >= scrollTop && targetRowBottom <= scrollBottom);
+ } else {
+ isVisible = (targetRow.offset >= scrollTop && targetRow.offset <= scrollBottom);
+ }
+
+ if (isVisible) {
+ const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize();
+ const maxScroll = totalRowsHeight - height;
+ this._hasScrolledToRowTarget = (scrollTop >= maxScroll || targetRow.offset === scrollTop);
+ } else if (scrollToRow !== prevProps.scrollToRow) {
+ this._hasScrolledToRowTarget = false;
+ }
+ } else {
+ this._hasScrolledToRowTarget = false;
+ }
+
// Special case where the previous size was 0:
// In this case we don't show any windowed cells at all.
// So we should always recalculate offset afterwards.
@@ -594,6 +666,8 @@ var Grid = function (_React$PureComponent) {
if (this._recomputeScrollLeftFlag) {
this._recomputeScrollLeftFlag = false;
this._updateScrollLeftForScrollToColumn(this.props);
+ } else if (this.props.scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) {
+ this._updateScrollLeftForScrollToColumn(this.props);
} else {
(0, _updateScrollIndexHelper2.default)({
cellSizeAndPositionManager: instanceProps.columnSizeAndPositionManager,
@@ -616,6 +690,8 @@ var Grid = function (_React$PureComponent) {
if (this._recomputeScrollTopFlag) {
this._recomputeScrollTopFlag = false;
this._updateScrollTopForScrollToRow(this.props);
+ } else if (this.props.scrollToRow >= 0 && !this._hasScrolledToRowTarget) {
+ this._updateScrollTopForScrollToRow(this.props);
} else {
(0, _updateScrollIndexHelper2.default)({
cellSizeAndPositionManager: instanceProps.rowSizeAndPositionManager,
@@ -630,11 +706,56 @@ var Grid = function (_React$PureComponent) {
size: height,
sizeJustIncreasedFromZero: sizeJustIncreasedFromZero,
updateScrollIndexCallback: function updateScrollIndexCallback() {
- return _this2._updateScrollTopForScrollToRow(_this2.props);
+ _this2._updateScrollLeftForScrollToColumn(_this2.props);
}
});
}
+ this._disableCellUpdates = false;
+ if (scrollPositionChangeReason !== SCROLL_POSITION_CHANGE_REASONS.OBSERVED) {
+ this._cellUpdates = [];
+ }
+ if (this._cellUpdates.length) {
+ const currentScrollTop = this.state.scrollTop;
+ const currentScrollBottom = currentScrollTop + height;
+ const currentScrollLeft = this.state.scrollLeft;
+ const currentScrollRight = currentScrollLeft + width;
+
+ let item;
+ let verticalDelta = 0;
+ let horizontalDelta = 0;
+
+ while (item = this._cellUpdates.shift()) {
+ const rowData = instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(item.rowIndex);
+ const columnData = instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(item.columnIndex);
+
+ const bottomOfItem = rowData.offset + rowData.size;
+ const rightSideOfItem = columnData.offset + columnData.size;
+
+ if (bottomOfItem < currentScrollBottom) {
+ verticalDelta += item.heightChange;
+ }
+ if (rightSideOfItem < currentScrollRight) {
+ horizontalDelta += item.widthChange;
+ }
+ }
+
+ if (this.props.scrollToRow >= 0 && !this._hasScrolledToRowTarget) {
+ verticalDelta = 0;
+ }
+ if (this.props.scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) {
+ horizontalDelta = 0;
+ }
+
+ if (verticalDelta !== 0 || horizontalDelta !== 0) {
+ this.setState(Grid._getScrollToPositionStateUpdate({
+ prevState: this.state,
+ scrollTop: scrollTop + verticalDelta,
+ scrollLeft: scrollLeft + horizontalDelta,
+ }));
+ }
+ }
+
// Update onRowsRendered callback if start/stop indices have changed
this._invokeOnGridRenderedHelper();
@@ -647,7 +768,11 @@ var Grid = function (_React$PureComponent) {
scrollLeft: scrollLeft,
scrollTop: scrollTop,
totalColumnsWidth: totalColumnsWidth,
- totalRowsHeight: totalRowsHeight
+ totalRowsHeight: totalRowsHeight,
+ scrollToColumn: scrollToColumn,
+ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget,
+ scrollToRow: scrollToRow,
+ _hasScrolledToRowTarget: this._hasScrolledToRowTarget,
});
}
@@ -962,7 +1087,11 @@ var Grid = function (_React$PureComponent) {
var scrollLeft = _ref6.scrollLeft,
scrollTop = _ref6.scrollTop,
totalColumnsWidth = _ref6.totalColumnsWidth,
- totalRowsHeight = _ref6.totalRowsHeight;
+ totalRowsHeight = _ref6.totalRowsHeight,
+ scrollToColumn = _ref6.scrollToColumn,
+ _hasScrolledToColumnTarget = _ref6._hasScrolledToColumnTarget,
+ scrollToRow = _ref6.scrollToRow,
+ _hasScrolledToRowTarget = _ref6._hasScrolledToRowTarget;
this._onScrollMemoizer({
callback: function callback(_ref7) {
@@ -973,19 +1102,26 @@ var Grid = function (_React$PureComponent) {
onScroll = _props7.onScroll,
width = _props7.width;
-
onScroll({
clientHeight: height,
clientWidth: width,
scrollHeight: totalRowsHeight,
scrollLeft: scrollLeft,
scrollTop: scrollTop,
- scrollWidth: totalColumnsWidth
+ scrollWidth: totalColumnsWidth,
+ scrollToColumn: scrollToColumn,
+ _hasScrolledToColumnTarget: _hasScrolledToColumnTarget,
+ scrollToRow: scrollToRow,
+ _hasScrolledToRowTarget: _hasScrolledToRowTarget,
});
},
indices: {
scrollLeft: scrollLeft,
- scrollTop: scrollTop
+ scrollTop: scrollTop,
+ scrollToColumn: scrollToColumn,
+ _hasScrolledToColumnTarget: _hasScrolledToColumnTarget,
+ scrollToRow: scrollToRow,
+ _hasScrolledToRowTarget: _hasScrolledToRowTarget,
}
});
}
diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js b/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js
index 70b0abe..2f04448 100644
--- a/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js
+++ b/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js
@@ -32,15 +32,8 @@ function defaultOverscanIndicesGetter(_ref) {
// For more info see issues #625
overscanCellsCount = Math.max(1, overscanCellsCount);
- if (scrollDirection === SCROLL_DIRECTION_FORWARD) {
- return {
- overscanStartIndex: Math.max(0, startIndex - 1),
- overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount)
- };
- } else {
- return {
- overscanStartIndex: Math.max(0, startIndex - overscanCellsCount),
- overscanStopIndex: Math.min(cellCount - 1, stopIndex + 1)
- };
- }
+ return {
+ overscanStartIndex: Math.max(0, startIndex - overscanCellsCount),
+ overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount),
+ };
}
diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js b/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js
index d5f6d04..e01e69a 100644
--- a/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js
+++ b/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js
@@ -27,15 +27,8 @@ function defaultOverscanIndicesGetter(_ref) {
startIndex = _ref.startIndex,
stopIndex = _ref.stopIndex;
- if (scrollDirection === SCROLL_DIRECTION_FORWARD) {
- return {
- overscanStartIndex: Math.max(0, startIndex),
- overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount)
- };
- } else {
- return {
- overscanStartIndex: Math.max(0, startIndex - overscanCellsCount),
- overscanStopIndex: Math.min(cellCount - 1, stopIndex)
- };
- }
+ return {
+ overscanStartIndex: Math.max(0, startIndex - overscanCellsCount),
+ overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount),
+ };
}
diff --git a/node_modules/react-virtualized/dist/commonjs/List/List.js b/node_modules/react-virtualized/dist/commonjs/List/List.js
index b5ad0eb..efb2cd7 100644
--- a/node_modules/react-virtualized/dist/commonjs/List/List.js
+++ b/node_modules/react-virtualized/dist/commonjs/List/List.js
@@ -112,13 +112,8 @@ var List = function (_React$PureComponent) {
}, _this._setRef = function (ref) {
_this.Grid = ref;
}, _this._onScroll = function (_ref3) {
- var clientHeight = _ref3.clientHeight,
- scrollHeight = _ref3.scrollHeight,
- scrollTop = _ref3.scrollTop;
var onScroll = _this.props.onScroll;
-
-
- onScroll({ clientHeight: clientHeight, scrollHeight: scrollHeight, scrollTop: scrollTop });
+ onScroll(_ref3);
}, _this._onSectionRendered = function (_ref4) {
var rowOverscanStartIndex = _ref4.rowOverscanStartIndex,
rowOverscanStopIndex = _ref4.rowOverscanStopIndex,

View file

@ -32,7 +32,7 @@
} }
@include dark-theme() { @include dark-theme() {
background-color: $color-black; background-color: $color-gray-95;
} }
} }
@ -66,24 +66,21 @@
} }
.main.panel { .main.panel {
.discussion-container { .timeline-placeholder {
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;
max-width: 100%; max-width: 100%;
margin: 0; margin: 0;
margin-bottom: 10px;
.bar-container { .timeline-wrapper {
height: 5px;
}
.message-list {
-webkit-padding-start: 0px; -webkit-padding-start: 0px;
position: absolute; position: absolute;
top: 0; top: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 10px 0 0 0; padding: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
@ -177,38 +174,6 @@
} }
} }
.message-container,
.message-list {
list-style: none;
.message-wrapper {
margin-left: 16px;
margin-right: 16px;
}
li {
margin-bottom: 10px;
&::after {
visibility: hidden;
display: block;
font-size: 0;
content: ' ';
clear: both;
height: 0;
}
}
}
.group {
.message-container,
.message-list {
.message-wrapper {
margin-left: 44px;
}
}
}
.typing-bubble-wrapper { .typing-bubble-wrapper {
margin-bottom: 20px; margin-bottom: 20px;
} }
@ -282,31 +247,6 @@
} }
} }
.attachment-previews {
padding: 0 36px;
margin-bottom: 3px;
.attachment-preview {
padding: 13px 10px 0;
}
img {
border: 2px solid #ddd;
border-radius: $border-radius;
max-height: 100px;
}
.close {
position: absolute;
top: 5px;
right: 2px;
background: #999;
&:hover {
background: $grey;
}
}
}
.flex { .flex {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -462,63 +402,3 @@
} }
} }
} }
.module-last-seen-indicator {
padding-top: 25px;
padding-bottom: 35px;
margin-left: 28px;
margin-right: 28px;
}
.module-last-seen-indicator__bar {
background-color: $color-light-60;
width: 100%;
height: 4px;
}
.module-last-seen-indicator__text {
margin-top: 3px;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
text-transform: uppercase;
text-align: center;
color: $color-light-90;
}
.module-scroll-down {
z-index: 100;
position: absolute;
right: 20px;
bottom: 10px;
}
.module-scroll-down__button {
height: 44px;
width: 44px;
border-radius: 22px;
text-align: center;
background-color: $color-light-35;
border: none;
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
outline: none;
&:hover {
background-color: $color-light-45;
}
}
.module-scroll-down__button--new-messages {
background-color: $color-signal-blue;
&:hover {
background-color: #1472bd;
}
}
.module-scroll-down__icon {
@include color-svg('../images/down.svg', $color-white);
height: 100%;
width: 100%;
}

View file

@ -81,15 +81,6 @@
height: 100%; height: 100%;
} }
.conversation-stack {
.conversation {
display: none;
}
.conversation:first-child {
display: block;
}
}
.tool-bar { .tool-bar {
color: $color-light-90; color: $color-light-90;

View file

@ -8,6 +8,22 @@
// Module: Message // Module: Message
// Note: this does the same thing as module-timeline__message-container but
// can be used outside tht Timeline contact more easily.
.module-message-container {
width: 100%;
margin-top: 10px;
&:after {
visibility: hidden;
display: block;
font-size: 0;
content: ' ';
clear: both;
height: 0;
}
}
.module-message { .module-message {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@ -39,15 +55,19 @@
// Spec: container < 438px // Spec: container < 438px
.module-message--incoming { .module-message--incoming {
margin-left: 0; margin-left: 16px;
margin-right: 32px; margin-right: 32px;
} }
.module-message--outgoing { .module-message--outgoing {
float: right; float: right;
margin-right: 0; margin-right: 16px;
margin-left: 32px; margin-left: 32px;
} }
.module-message--incoming.module-message--group {
margin-left: 44px;
}
.module-message__buttons { .module-message__buttons {
position: absolute; position: absolute;
top: 0; top: 0;
@ -165,6 +185,37 @@
background-color: $color-light-10; background-color: $color-light-10;
} }
.module-message__container__selection {
position: absolute;
display: block;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 16px;
background-color: $color-black;
opacity: 0;
animation: message--selected 1s ease-in-out;
}
@keyframes message--selected {
0% {
opacity: 0;
}
20% {
opacity: 0.3;
}
80% {
opacity: 0.3;
}
100% {
opacity: 0;
}
}
// In case the color gets messed up // In case the color gets messed up
.module-message__container--incoming { .module-message__container--incoming {
background-color: $color-conversation-grey; background-color: $color-conversation-grey;
@ -704,10 +755,10 @@
.module-message__metadata__status-icon--sending { .module-message__metadata__status-icon--sending {
@include color-svg('../images/sending.svg', $color-gray-60); @include color-svg('../images/sending.svg', $color-gray-60);
animation: module-message__metdata__status-icon--spinning 4s linear infinite; animation: module-message__metadata__status-icon--spinning 4s linear infinite;
} }
@keyframes module-message__metdata__status-icon--spinning { @keyframes module-message__metadata__status-icon--spinning {
100% { 100% {
-webkit-transform: rotate(360deg); -webkit-transform: rotate(360deg);
transform: rotate(360deg); transform: rotate(360deg);
@ -842,6 +893,9 @@
} }
.module-quote { .module-quote {
// To leave room for image thumbnail
min-height: 54px;
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
border-top-left-radius: 10px; border-top-left-radius: 10px;
@ -1286,6 +1340,8 @@
.module-group-notification { .module-group-notification {
margin-top: 14px; margin-top: 14px;
margin-left: 1em;
margin-right: 1em;
font-size: 14px; font-size: 14px;
line-height: 20px; line-height: 20px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
@ -2420,6 +2476,13 @@
background-color: $color-black-02; background-color: $color-black-02;
} }
.module-image__border-overlay--selected {
background-color: $color-black;
opacity: 0;
animation: message--selected 1s ease-in-out;
}
.module-image__loading-placeholder { .module-image__loading-placeholder {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
@ -2999,9 +3062,16 @@
// In these --small and --mini sizes, we're exploding our @color-svg mixin so we don't // In these --small and --mini sizes, we're exploding our @color-svg mixin so we don't
// have to duplicate our background colors for the dark/ios/size matrix. // have to duplicate our background colors for the dark/ios/size matrix.
.module-spinner__container--small {
height: 24px;
width: 24px;
}
.module-spinner__circle--small { .module-spinner__circle--small {
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center; -webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
-webkit-mask-size: 100%; -webkit-mask-size: 100%;
height: 24px;
width: 24px;
} }
.module-spinner__arc--small { .module-spinner__arc--small {
-webkit-mask: url('../images/spinner-24.svg') no-repeat center; -webkit-mask: url('../images/spinner-24.svg') no-repeat center;
@ -3023,6 +3093,8 @@
.module-spinner__arc--mini { .module-spinner__arc--mini {
-webkit-mask: url('../images/spinner-24.svg') no-repeat center; -webkit-mask: url('../images/spinner-24.svg') no-repeat center;
-webkit-mask-size: 100%; -webkit-mask-size: 100%;
height: 14px;
width: 14px;
} }
.module-spinner__circle--incoming { .module-spinner__circle--incoming {
@ -3032,6 +3104,13 @@
background-color: $color-white; background-color: $color-white;
} }
.module-spinner__circle--on-background {
background-color: $color-gray-05;
}
.module-spinner__arc--on-background {
background-color: $color-gray-60;
}
// Module: Highlighted Message Body // Module: Highlighted Message Body
.module-message-body__highlight { .module-message-body__highlight {
@ -3306,10 +3385,31 @@
font-size: 13px; font-size: 13px;
} }
// Module: Timeline Loading Row
.module-timeline-loading-row {
height: 48px;
padding: 12px;
display: flex;
flex-direction: columns;
justify-content: center;
align-items: center;
@include light-theme {
color: $color-gray-75;
}
@include dark-theme {
color: $color-gray-25;
}
}
// Module: Timeline // Module: Timeline
.module-timeline { .module-timeline {
height: 100%; height: 100%;
overflow: hidden;
} }
.module-timeline__message-container { .module-timeline__message-container {
@ -4686,13 +4786,35 @@
.module-countdown { .module-countdown {
display: block; display: block;
width: 100%; width: 24px;
height: 24px;
} }
.module-countdown__path { // Note: the colors here should match the module-spinner's on-background colors
.module-countdown__front-path {
fill-opacity: 0; fill-opacity: 0;
stroke: $color-white;
stroke-width: 2; stroke-width: 2;
@include light-theme {
stroke: $color-gray-60;
}
@include dark-theme {
stroke: $color-gray-25;
}
}
.module-countdown__back-path {
fill-opacity: 0;
stroke-width: 2;
@include light-theme {
stroke: $color-gray-05;
}
@include dark-theme {
stroke: $color-gray-75;
}
} }
// Module: CompositionInput // Module: CompositionInput
@ -4913,6 +5035,70 @@
} }
} }
// Module: Last Seen Indicator
.module-last-seen-indicator {
padding-top: 25px;
padding-bottom: 35px;
margin-left: 28px;
margin-right: 28px;
}
.module-last-seen-indicator__bar {
background-color: $color-light-60;
width: 100%;
height: 4px;
}
.module-last-seen-indicator__text {
margin-top: 3px;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
text-transform: uppercase;
text-align: center;
color: $color-light-90;
}
// Module: Scroll Down Button
.module-scroll-down {
z-index: 100;
position: absolute;
right: 20px;
bottom: 10px;
}
.module-scroll-down__button {
height: 44px;
width: 44px;
border-radius: 22px;
text-align: center;
background-color: $color-light-35;
border: none;
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
outline: none;
&:hover {
background-color: $color-light-45;
}
}
.module-scroll-down__button--new-messages {
background-color: $color-signal-blue;
&:hover {
background-color: #1472bd;
}
}
.module-scroll-down__icon {
@include color-svg('../images/down.svg', $color-white);
height: 100%;
width: 100%;
}
// Third-party module: react-contextmenu // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {
@ -5016,11 +5202,9 @@
// Spec: container < 438px // Spec: container < 438px
.module-message--incoming { .module-message--incoming {
margin-left: 0;
margin-right: auto; margin-right: auto;
} }
.module-message--outgoing { .module-message--outgoing {
margin-right: 0;
margin-left: auto; margin-left: auto;
} }
@ -5051,11 +5235,9 @@
} }
.module-message--incoming { .module-message--incoming {
margin-left: 0;
margin-right: auto; margin-right: auto;
} }
.module-message--outgoing { .module-message--outgoing {
margin-right: 0;
margin-left: auto; margin-left: auto;
} }

View file

@ -1509,6 +1509,13 @@ body.dark-theme {
background-color: $color-gray-05; background-color: $color-gray-05;
} }
.module-spinner__circle--on-background {
background-color: $color-gray-75;
}
.module-spinner__arc--on-background {
background-color: $color-gray-25;
}
// Module: Caption Editor // Module: Caption Editor
.module-caption-editor { .module-caption-editor {

View file

@ -487,9 +487,7 @@
<script type='text/javascript' src='../js/views/file_input_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/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/contact_list_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> <script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script> <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/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_view.js' data-cover></script>
@ -497,19 +495,14 @@
<script type='text/javascript' src='../js/views/network_status_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' data-cover></script> <script type='text/javascript' src='../js/views/confirmation_dialog_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/identicon_svg_view.js' data-cover></script> <script type='text/javascript' src='../js/views/identicon_svg_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/last_seen_indicator_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script> <script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/clear_data_view.js'></script> <script type='text/javascript' src='../js/views/clear_data_view.js'></script>
<script type="text/javascript" src="metadata/SecretSessionCipher_test.js"></script> <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/whisper_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/list_view_test.js"></script>
<script type="text/javascript" src="views/network_status_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>
<script type="text/javascript" src="models/messages_test.js"></script> <script type="text/javascript" src="models/messages_test.js"></script>

View file

@ -34,17 +34,19 @@ describe('KeyChangeListener', () => {
}); });
after(async () => { after(async () => {
await convo.destroyMessages(); await window.Signal.Data.removeAllMessagesInConversation(convo.id, {
MessageCollection: Whisper.MessageCollection,
});
await window.Signal.Data.saveConversation(convo.id); await window.Signal.Data.saveConversation(convo.id);
}); });
it('generates a key change notice in the private conversation with this contact', done => { it('generates a key change notice in the private conversation with this contact', done => {
convo.once('newmessage', async () => { const original = convo.addKeyChange;
await convo.fetchMessages(); convo.addKeyChange = keyChangedId => {
const message = convo.messageCollection.at(0); assert.equal(address.getName(), keyChangedId);
assert.strictEqual(message.get('type'), 'keychange'); convo.addKeyChange = original;
done(); done();
}); };
store.saveIdentity(address.toString(), newKey); store.saveIdentity(address.toString(), newKey);
}); });
}); });
@ -62,17 +64,20 @@ describe('KeyChangeListener', () => {
}); });
}); });
after(async () => { after(async () => {
await convo.destroyMessages(); await window.Signal.Data.removeAllMessagesInConversation(convo.id, {
MessageCollection: Whisper.MessageCollection,
});
await window.Signal.Data.saveConversation(convo.id); await window.Signal.Data.saveConversation(convo.id);
}); });
it('generates a key change notice in the group conversation with this contact', done => { it('generates a key change notice in the group conversation with this contact', done => {
convo.once('newmessage', async () => { const original = convo.addKeyChange;
await convo.fetchMessages(); convo.addKeyChange = keyChangedId => {
const message = convo.messageCollection.at(0); assert.equal(address.getName(), keyChangedId);
assert.strictEqual(message.get('type'), 'keychange'); convo.addKeyChange = original;
done(); done();
}); };
store.saveIdentity(address.toString(), newKey); store.saveIdentity(address.toString(), newKey);
}); });
}); });

View file

@ -72,22 +72,6 @@ describe('Conversation', () => {
assert.strictEqual(convo.contactCollection.at('2').get('name'), 'C'); assert.strictEqual(convo.contactCollection.at('2').get('name'), 'C');
}); });
it('contains its own messages', async () => {
const convo = new Whisper.ConversationCollection().add({
id: '+18085555555',
});
await convo.fetchMessages();
assert.notEqual(convo.messageCollection.length, 0);
});
it('contains only its own messages', async () => {
const convo = new Whisper.ConversationCollection().add({
id: '+18085556666',
});
await convo.fetchMessages();
assert.strictEqual(convo.messageCollection.length, 0);
});
it('adds conversation to message collection upon leaving group', async () => { it('adds conversation to message collection upon leaving group', async () => {
const convo = new Whisper.ConversationCollection().add({ const convo = new Whisper.ConversationCollection().add({
type: 'group', type: 'group',

View file

@ -1,32 +0,0 @@
/* global Whisper */
describe('LastSeenIndicatorView', () => {
it('renders provided count', () => {
const view = new Whisper.LastSeenIndicatorView({ count: 10 });
assert.equal(view.count, 10);
view.render();
assert.match(view.$el.html(), /10 Unread Messages/);
});
it('renders count of 1', () => {
const view = new Whisper.LastSeenIndicatorView({ count: 1 });
assert.equal(view.count, 1);
view.render();
assert.match(view.$el.html(), /1 Unread Message/);
});
it('increments count', () => {
const view = new Whisper.LastSeenIndicatorView({ count: 4 });
assert.equal(view.count, 4);
view.render();
assert.match(view.$el.html(), /4 Unread Messages/);
view.increment(3);
assert.equal(view.count, 7);
view.render();
assert.match(view.$el.html(), /7 Unread Messages/);
});
});

View file

@ -1,35 +0,0 @@
/* global Whisper */
describe('ScrollDownButtonView', () => {
it('renders with count = 0', () => {
const view = new Whisper.ScrollDownButtonView();
view.render();
assert.equal(view.count, 0);
assert.match(view.$el.html(), /Scroll to bottom/);
});
it('renders with count = 1', () => {
const view = new Whisper.ScrollDownButtonView({ count: 1 });
view.render();
assert.equal(view.count, 1);
assert.match(view.$el.html(), /New message below/);
});
it('renders with count = 2', () => {
const view = new Whisper.ScrollDownButtonView({ count: 2 });
view.render();
assert.equal(view.count, 2);
assert.match(view.$el.html(), /New messages below/);
});
it('increments count and re-renders', () => {
const view = new Whisper.ScrollDownButtonView();
view.render();
assert.equal(view.count, 0);
assert.notMatch(view.$el.html(), /New message below/);
view.increment(1);
assert.equal(view.count, 1);
assert.match(view.$el.html(), /New message below/);
});
});

View file

@ -152,7 +152,9 @@
conversationType={'direct'} conversationType={'direct'}
unreadCount={4} unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000} lastUpdated={Date.now() - 5 * 60 * 1000}
isTyping={true} typingContact={{
name: 'Someone Here',
}}
onClick={result => console.log('onClick', result)} onClick={result => console.log('onClick', result)}
i18n={util.i18n} i18n={util.i18n}
/> />
@ -164,7 +166,9 @@
conversationType={'direct'} conversationType={'direct'}
unreadCount={4} unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000} lastUpdated={Date.now() - 5 * 60 * 1000}
isTyping={true} typingContact={{
name: 'Someone Here',
}}
lastMessage={{ lastMessage={{
status: 'read', status: 'read',
}} }}

View file

@ -23,7 +23,7 @@ export type PropsData = {
unreadCount: number; unreadCount: number;
isSelected: boolean; isSelected: boolean;
isTyping: boolean; typingContact?: Object;
lastMessage?: { lastMessage?: {
status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
text: string; text: string;
@ -134,8 +134,8 @@ export class ConversationListItem extends React.PureComponent<Props> {
} }
public renderMessage() { public renderMessage() {
const { lastMessage, isTyping, unreadCount, i18n } = this.props; const { lastMessage, typingContact, unreadCount, i18n } = this.props;
if (!lastMessage && !isTyping) { if (!lastMessage && !typingContact) {
return null; return null;
} }
const text = lastMessage && lastMessage.text ? lastMessage.text : ''; const text = lastMessage && lastMessage.text ? lastMessage.text : '';
@ -150,15 +150,22 @@ export class ConversationListItem extends React.PureComponent<Props> {
: null : null
)} )}
> >
{isTyping ? ( {typingContact ? (
<TypingAnimation i18n={i18n} /> <TypingAnimation i18n={i18n} />
) : ( ) : (
<MessageBody <>
text={text} {shouldShowDraft ? (
disableJumbomoji={true} <span className="module-conversation-list-item__message__draft-prefix">
disableLinks={true} {i18n('ConversationListItem--draft-prefix')}
i18n={i18n} </span>
/> ) : null}
<MessageBody
text={text}
disableJumbomoji={true}
disableLinks={true}
i18n={i18n}
/>
</>
)} )}
</div> </div>
{lastMessage && lastMessage.status ? ( {lastMessage && lastMessage.status ? (

View file

@ -73,16 +73,25 @@ export class Countdown extends React.Component<Props, State> {
const strokeDashoffset = ratio * CIRCUMFERENCE; const strokeDashoffset = ratio * CIRCUMFERENCE;
return ( return (
<svg className="module-countdown" viewBox="0 0 24 24"> <div className="module-countdown">
<path <svg viewBox="0 0 24 24">
d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z" <path
className="module-countdown__path" d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z"
style={{ className="module-countdown__back-path"
strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`, style={{
strokeDashoffset, strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`,
}} }}
/> />
</svg> <path
d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z"
className="module-countdown__front-path"
style={{
strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`,
strokeDashoffset,
}}
/>
</svg>
</div>
); );
} }
} }

View file

@ -3,10 +3,7 @@ import {
ConversationListItem, ConversationListItem,
PropsData as ConversationListItemPropsType, PropsData as ConversationListItemPropsType,
} from './ConversationListItem'; } from './ConversationListItem';
import { import { MessageSearchResult } from './MessageSearchResult';
MessageSearchResult,
PropsData as MessageSearchResultPropsType,
} from './MessageSearchResult';
import { StartNewConversation } from './StartNewConversation'; import { StartNewConversation } from './StartNewConversation';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';

View file

@ -4,7 +4,7 @@ import classNames from 'classnames';
interface Props { interface Props {
size?: string; size?: string;
svgSize: 'small' | 'normal'; svgSize: 'small' | 'normal';
direction?: string; direction?: 'outgoing' | 'incoming' | 'on-background';
} }
export class Spinner extends React.Component<Props> { export class Spinner extends React.Component<Props> {

View file

@ -23,7 +23,7 @@ const contact = {
signalAccount: '+12025550000', signalAccount: '+12025550000',
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -31,8 +31,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -41,8 +41,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -51,8 +51,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -62,7 +62,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -89,7 +89,7 @@ const contact = {
signalAccount: '+12025550000', signalAccount: '+12025550000',
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -97,8 +97,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -107,7 +107,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -131,15 +131,15 @@ const contact = {
}, },
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
i18n={util.i18n} i18n={util.i18n}
timestamp={Date.now()} timestamp={Date.now()}
contact={contact}/> contact={contact}/>
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -147,7 +147,7 @@ const contact = {
i18n={util.i18n} i18n={util.i18n}
timestamp={Date.now()} timestamp={Date.now()}
contact={contact}/> contact={contact}/>
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -171,8 +171,8 @@ const contact = {
}, },
signalAccount: '+12025550000', signalAccount: '+12025550000',
}; };
<util.ConversationContext theme={util.theme} type="group" ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
conversationType="group" conversationType="group"
@ -183,8 +183,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -195,8 +195,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -207,7 +207,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -231,7 +231,7 @@ const contact = {
}, },
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -239,8 +239,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -249,8 +249,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -259,8 +259,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -270,7 +270,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -292,7 +292,7 @@ const contact = {
}, },
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -300,8 +300,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -310,8 +310,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -320,8 +320,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -331,7 +331,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -356,7 +356,7 @@ const contact = {
signalAccount: '+12025551000', signalAccount: '+12025551000',
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -364,8 +364,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -374,8 +374,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -384,8 +384,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -395,7 +395,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -414,7 +414,7 @@ const contact = {
], ],
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -422,8 +422,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -432,8 +432,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -442,8 +442,8 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -453,7 +453,7 @@ const contact = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -462,7 +462,7 @@ const contact = {
```jsx ```jsx
const contact = {}; const contact = {};
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -470,8 +470,8 @@ const contact = {};
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -480,8 +480,8 @@ const contact = {};
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="incoming" direction="incoming"
@ -490,8 +490,8 @@ const contact = {};
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="green" authorColor="green"
direction="outgoing" direction="outgoing"
@ -501,7 +501,7 @@ const contact = {};
timestamp={Date.now()} timestamp={Date.now()}
contact={contact} contact={contact}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -542,7 +542,7 @@ const contactWithoutAccount = {
}, },
}; };
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -551,8 +551,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithAccount} contact={contactWithAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -562,8 +562,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithAccount} contact={contactWithAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -572,8 +572,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithAccount} contact={contactWithAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -583,8 +583,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithAccount} contact={contactWithAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -594,8 +594,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithoutAccount} contact={contactWithoutAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -606,8 +606,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithoutAccount} contact={contactWithoutAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -617,8 +617,8 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithoutAccount} contact={contactWithoutAccount}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
text="I want to introduce you to Someone..." text="I want to introduce you to Someone..."
authorColor="green" authorColor="green"
@ -629,6 +629,6 @@ const contactWithoutAccount = {
timestamp={Date.now()} timestamp={Date.now()}
contact={contactWithoutAccount} contact={contactWithoutAccount}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```

View file

@ -2,7 +2,7 @@
```jsx ```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -13,8 +13,8 @@
expirationLength={10 * 1000} expirationLength={10 * 1000}
expirationTimestamp={Date.now() + 10 * 1000} expirationTimestamp={Date.now() + 10 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="outgoing" direction="outgoing"
status="delivered" status="delivered"
@ -25,8 +25,8 @@
expirationLength={30 * 1000} expirationLength={30 * 1000}
expirationTimestamp={Date.now() + 30 * 1000} expirationTimestamp={Date.now() + 30 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -37,8 +37,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000} expirationTimestamp={Date.now() + 55 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -49,7 +49,7 @@
expirationLength={5 * 60 * 1000} expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000} expirationTimestamp={Date.now() + 5 * 60 * 1000}
/> />
</li> </div>
</util.ConversationContext> </util.ConversationContext>
``` ```
@ -57,7 +57,7 @@
```jsx ```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -67,8 +67,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 60 * 1000} expirationTimestamp={Date.now() + 60 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -79,8 +79,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 60 * 1000} expirationTimestamp={Date.now() + 60 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -90,8 +90,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000} expirationTimestamp={Date.now() + 55 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -102,8 +102,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000} expirationTimestamp={Date.now() + 55 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -113,8 +113,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 30 * 1000} expirationTimestamp={Date.now() + 30 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -125,8 +125,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 30 * 1000} expirationTimestamp={Date.now() + 30 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -136,8 +136,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 5 * 1000} expirationTimestamp={Date.now() + 5 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -148,8 +148,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 5 * 1000} expirationTimestamp={Date.now() + 5 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -159,8 +159,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now()} expirationTimestamp={Date.now()}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -171,8 +171,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now()} expirationTimestamp={Date.now()}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -182,8 +182,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 120 * 1000} expirationTimestamp={Date.now() + 120 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -194,8 +194,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 120 * 1000} expirationTimestamp={Date.now() + 120 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="incoming" direction="incoming"
@ -205,8 +205,8 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() - 20 * 1000} expirationTimestamp={Date.now() - 20 * 1000}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
authorColor="blue" authorColor="blue"
direction="outgoing" direction="outgoing"
@ -217,6 +217,6 @@
expirationLength={60 * 1000} expirationLength={60 * 1000}
expirationTimestamp={Date.now() - 20 * 1000} expirationTimestamp={Date.now() - 20 * 1000}
/> />
</li> </div>
</util.ConversationContext> </util.ConversationContext>
``` ```

View file

@ -15,6 +15,7 @@ interface Props {
overlayText?: string; overlayText?: string;
isSelected?: boolean;
noBorder?: boolean; noBorder?: boolean;
noBackground?: boolean; noBackground?: boolean;
bottomOverlay?: boolean; bottomOverlay?: boolean;
@ -51,6 +52,7 @@ export class Image extends React.Component<Props> {
darkOverlay, darkOverlay,
height, height,
i18n, i18n,
isSelected,
noBackground, noBackground,
noBorder, noBorder,
onClick, onClick,
@ -118,7 +120,7 @@ export class Image extends React.Component<Props> {
alt={i18n('imageCaptionIconAlt')} alt={i18n('imageCaptionIconAlt')}
/> />
) : null} ) : null}
{!noBorder ? ( {!noBorder || isSelected ? (
<div <div
className={classNames( className={classNames(
'module-image__border-overlay', 'module-image__border-overlay',
@ -128,7 +130,8 @@ export class Image extends React.Component<Props> {
curveBottomRight ? 'module-image--curved-bottom-right' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null,
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null, smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
softCorners ? 'module-image--soft-corners' : null, softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null darkOverlay ? 'module-image__border-overlay--dark' : null,
isSelected ? 'module-image__border-overlay--selected' : null
)} )}
/> />
) : null} ) : null}

View file

@ -21,6 +21,7 @@ interface Props {
withContentBelow?: boolean; withContentBelow?: boolean;
bottomOverlay?: boolean; bottomOverlay?: boolean;
isSticker?: boolean; isSticker?: boolean;
isSelected?: boolean;
stickerSize?: number; stickerSize?: number;
i18n: LocalizerType; i18n: LocalizerType;
@ -37,6 +38,7 @@ export class ImageGrid extends React.Component<Props> {
bottomOverlay, bottomOverlay,
i18n, i18n,
isSticker, isSticker,
isSelected,
stickerSize, stickerSize,
onError, onError,
onClick, onClick,
@ -83,6 +85,7 @@ export class ImageGrid extends React.Component<Props> {
curveBottomRight={curveBottomRight} curveBottomRight={curveBottomRight}
attachment={attachments[0]} attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])} playIconOverlay={isVideoAttachment(attachments[0])}
isSelected={isSelected}
height={finalHeight} height={finalHeight}
width={finalWidth} width={finalWidth}
url={getUrl(attachments[0])} url={getUrl(attachments[0])}

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ interface Trigger {
// Same as MIN_WIDTH in ImageGrid.tsx // Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const STICKER_SIZE = 128; const STICKER_SIZE = 128;
const SELECTED_TIMEOUT = 1000;
interface LinkPreviewType { interface LinkPreviewType {
title: string; title: string;
@ -54,6 +55,8 @@ export type PropsData = {
text?: string; text?: string;
textPending?: boolean; textPending?: boolean;
isSticker: boolean; isSticker: boolean;
isSelected: boolean;
isSelectedCounter: number;
direction: 'incoming' | 'outgoing'; direction: 'incoming' | 'outgoing';
timestamp: number; timestamp: number;
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
@ -97,6 +100,8 @@ type PropsHousekeeping = {
}; };
export type PropsActions = { export type PropsActions = {
clearSelectedMessage: () => unknown;
replyToMessage: (id: string) => void; replyToMessage: (id: string) => void;
retrySend: (id: string) => void; retrySend: (id: string) => void;
deleteMessage: (id: string) => void; deleteMessage: (id: string) => void;
@ -120,11 +125,10 @@ export type PropsActions = {
displayTapToViewMessage: (messageId: string) => unknown; displayTapToViewMessage: (messageId: string) => unknown;
openLink: (url: string) => void; openLink: (url: string) => void;
scrollToMessage: ( scrollToQuotedMessage: (
options: { options: {
author: string; author: string;
sentAt: number; sentAt: number;
referencedMessageNotFound: boolean;
} }
) => void; ) => void;
}; };
@ -135,6 +139,9 @@ interface State {
expiring: boolean; expiring: boolean;
expired: boolean; expired: boolean;
imageBroken: boolean; imageBroken: boolean;
isSelected: boolean;
prevSelectedCounter: number;
} }
const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRATION_CHECK_MINIMUM = 2000;
@ -148,6 +155,7 @@ export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined; public menuTriggerRef: Trigger | undefined;
public expirationCheckInterval: any; public expirationCheckInterval: any;
public expiredTimeout: any; public expiredTimeout: any;
public selectedTimeout: any;
public constructor(props: Props) { public constructor(props: Props) {
super(props); super(props);
@ -160,10 +168,30 @@ export class Message extends React.PureComponent<Props, State> {
expiring: false, expiring: false,
expired: false, expired: false,
imageBroken: false, imageBroken: false,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
}; };
} }
public static getDerivedStateFromProps(props: Props, state: State): State {
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
) {
return {
...state,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
}
return state;
}
public componentDidMount() { public componentDidMount() {
this.startSelectedTimer();
const { expirationLength } = this.props; const { expirationLength } = this.props;
if (!expirationLength) { if (!expirationLength) {
return; return;
@ -180,6 +208,9 @@ export class Message extends React.PureComponent<Props, State> {
} }
public componentWillUnmount() { public componentWillUnmount() {
if (this.selectedTimeout) {
clearInterval(this.selectedTimeout);
}
if (this.expirationCheckInterval) { if (this.expirationCheckInterval) {
clearInterval(this.expirationCheckInterval); clearInterval(this.expirationCheckInterval);
} }
@ -189,9 +220,26 @@ export class Message extends React.PureComponent<Props, State> {
} }
public componentDidUpdate() { public componentDidUpdate() {
this.startSelectedTimer();
this.checkExpired(); this.checkExpired();
} }
public startSelectedTimer() {
const { isSelected } = this.state;
if (!isSelected) {
return;
}
if (!this.selectedTimeout) {
this.selectedTimeout = setTimeout(() => {
this.selectedTimeout = undefined;
this.setState({ isSelected: false });
this.props.clearSelectedMessage();
}, SELECTED_TIMEOUT);
}
}
public checkExpired() { public checkExpired() {
const now = Date.now(); const now = Date.now();
const { isExpired, expirationTimestamp, expirationLength } = this.props; const { isExpired, expirationTimestamp, expirationLength } = this.props;
@ -379,7 +427,7 @@ export class Message extends React.PureComponent<Props, State> {
isSticker, isSticker,
text, text,
} = this.props; } = this.props;
const { imageBroken } = this.state; const { imageBroken, isSelected } = this.state;
if (!attachments || !attachments[0]) { if (!attachments || !attachments[0]) {
return null; return null;
@ -422,6 +470,7 @@ export class Message extends React.PureComponent<Props, State> {
withContentAbove={isSticker || withContentAbove} withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow} withContentBelow={isSticker || withContentBelow}
isSticker={isSticker} isSticker={isSticker}
isSelected={isSticker && isSelected}
stickerSize={STICKER_SIZE} stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay} bottomOverlay={bottomOverlay}
i18n={i18n} i18n={i18n}
@ -622,7 +671,7 @@ export class Message extends React.PureComponent<Props, State> {
disableScroll, disableScroll,
i18n, i18n,
quote, quote,
scrollToMessage, scrollToQuotedMessage,
} = this.props; } = this.props;
if (!quote) { if (!quote) {
@ -633,15 +682,14 @@ export class Message extends React.PureComponent<Props, State> {
conversationType === 'group' && direction === 'incoming'; conversationType === 'group' && direction === 'incoming';
const quoteColor = const quoteColor =
direction === 'incoming' ? authorColor : quote.authorColor; direction === 'incoming' ? authorColor : quote.authorColor;
const { referencedMessageNotFound } = quote; const { referencedMessageNotFound } = quote;
const clickHandler = disableScroll const clickHandler = disableScroll
? undefined ? undefined
: () => { : () => {
scrollToMessage({ scrollToQuotedMessage({
author: quote.authorId, author: quote.authorId,
sentAt: quote.sentAt, sentAt: quote.sentAt,
referencedMessageNotFound,
}); });
}; };
@ -1195,12 +1243,24 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
public renderSelectionHighlight() {
const { isSticker } = this.props;
const { isSelected } = this.state;
if (!isSelected || isSticker) {
return;
}
return <div className="module-message__container__selection" />;
}
// tslint:disable-next-line cyclomatic-complexity // tslint:disable-next-line cyclomatic-complexity
public render() { public render() {
const { const {
authorPhoneNumber, authorPhoneNumber,
authorColor, authorColor,
attachments, attachments,
conversationType,
direction, direction,
displayTapToViewMessage, displayTapToViewMessage,
id, id,
@ -1211,6 +1271,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp, timestamp,
} = this.props; } = this.props;
const { expired, expiring, imageBroken } = this.state; const { expired, expiring, imageBroken } = this.state;
const isAttachmentPending = this.isAttachmentPending(); const isAttachmentPending = this.isAttachmentPending();
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending; const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
@ -1236,7 +1297,8 @@ export class Message extends React.PureComponent<Props, State> {
className={classNames( className={classNames(
'module-message', 'module-message',
`module-message--${direction}`, `module-message--${direction}`,
expiring ? 'module-message--expired' : null expiring ? 'module-message--expired' : null,
conversationType === 'group' ? 'module-message--group' : null
)} )}
> >
{this.renderError(direction === 'incoming')} {this.renderError(direction === 'incoming')}
@ -1271,6 +1333,7 @@ export class Message extends React.PureComponent<Props, State> {
> >
{this.renderAuthor()} {this.renderAuthor()}
{this.renderContents()} {this.renderContents()}
{this.renderSelectionHighlight()}
{this.renderAvatar()} {this.renderAvatar()}
</div> </div>
{this.renderError(direction === 'outgoing')} {this.renderError(direction === 'outgoing')}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
// import classNames from 'classnames';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Intl } from '../Intl'; import { Intl } from '../Intl';

View file

@ -1,9 +1,9 @@
### None ### No new messages
```jsx ```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton <ScrollDownButton
count={0} withNewMessages={false}
conversationId="id-1" conversationId="id-1"
scrollDown={id => console.log('scrollDown', id)} scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n} i18n={util.i18n}
@ -11,28 +11,15 @@
</util.ConversationContext> </util.ConversationContext>
``` ```
### One ### With new messages
```jsx ```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton <ScrollDownButton
count={1} withNewMessages={true}
conversationId="id-2" conversationId="id-2"
scrollDown={id => console.log('scrollDown', id)} scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n} i18n={util.i18n}
/> />
</util.ConversationContext> </util.ConversationContext>
``` ```
### More than one
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={2}
conversationId="id-3"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
/>
</util.ConversationContext>
```

View file

@ -4,7 +4,7 @@ import classNames from 'classnames';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
type Props = { type Props = {
count: number; withNewMessages: boolean;
conversationId: string; conversationId: string;
scrollDown: (conversationId: string) => void; scrollDown: (conversationId: string) => void;
@ -14,21 +14,17 @@ type Props = {
export class ScrollDownButton extends React.Component<Props> { export class ScrollDownButton extends React.Component<Props> {
public render() { public render() {
const { conversationId, count, i18n, scrollDown } = this.props; const { conversationId, withNewMessages, i18n, scrollDown } = this.props;
const altText = withNewMessages
let altText = i18n('scrollDown'); ? i18n('messagesBelow')
if (count > 1) { : i18n('scrollDown');
altText = i18n('messagesBelow');
} else if (count === 1) {
altText = i18n('messageBelow');
}
return ( return (
<div className="module-scroll-down"> <div className="module-scroll-down">
<button <button
className={classNames( className={classNames(
'module-scroll-down__button', 'module-scroll-down__button',
count > 0 ? 'module-scroll-down__button--new-messages' : null withNewMessages ? 'module-scroll-down__button--new-messages' : null
)} )}
onClick={() => { onClick={() => {
scrollDown(conversationId); scrollDown(conversationId);

View file

@ -1,5 +1,7 @@
```javascript ## With oldest and newest
const itemLookup = {
```jsx
window.itemLookup = {
'id-1': { 'id-1': {
type: 'message', type: 'message',
data: { data: {
@ -15,12 +17,24 @@ const itemLookup = {
type: 'message', type: 'message',
data: { data: {
id: 'id-2', id: 'id-2',
conversationType: 'group',
direction: 'incoming', direction: 'incoming',
timestamp: Date.now(), timestamp: Date.now(),
authorColor: 'green', authorColor: 'green',
text: 'Hello there from the new world! http://somewhere.com', text: 'Hello there from the new world! http://somewhere.com',
}, },
}, },
'id-2.5': {
type: 'unsupportedMessage',
data: {
id: 'id-2.5',
canProcessNow: false,
contact: {
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Pig',
},
},
},
'id-3': { 'id-3': {
type: 'message', type: 'message',
data: { data: {
@ -155,25 +169,186 @@ const itemLookup = {
}, },
}; };
const actions = { window.actions = {
// For messages
downloadAttachment: options => console.log('onDownload', options), downloadAttachment: options => console.log('onDownload', options),
replyToitem: id => console.log('onReply', id), replyToitem: id => console.log('onReply', id),
showMessageDetail: id => console.log('onShowDetail', id), showMessageDetail: id => console.log('onShowDetail', id),
deleteMessage: id => console.log('onDelete', id), deleteMessage: id => console.log('onDelete', id),
downloadNewVersion: () => console.log('downloadNewVersion'),
// For Timeline
clearChangedMessages: (...args) => console.log('clearChangedMessages', args),
setLoadCountdownStart: (...args) =>
console.log('setLoadCountdownStart', args),
loadAndScroll: (...args) => console.log('loadAndScroll', args),
loadOlderMessages: (...args) => console.log('loadOlderMessages', args),
loadNewerMessages: (...args) => console.log('loadNewerMessages', args),
loadNewestMessages: (...args) => console.log('loadNewestMessages', args),
markMessageRead: (...args) => console.log('markMessageRead', args),
}; };
const items = util._.keys(itemLookup); const props = {
const renderItem = id => { id: 'conversationId-1',
const item = itemLookup[id]; haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 0,
// Because we can't use ...item syntax renderItem: id => (
return React.createElement( <TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
TimelineItem, ),
util._.merge({ item, i18n: util.i18n }, actions)
);
}; };
<div style={{ height: '300px' }}> <div style={{ height: '300px' }}>
<Timeline items={items} renderItem={renderItem} i18n={util.i18n} /> <Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## With last seen indicator
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: 2,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 2,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
renderLastSeenIndicator: () => (
<LastSeenIndicator count={2} i18n={util.i18n} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## With target index = 0
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: 0,
scrollToIndexCounter: 0,
totalUnread: 0,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## With typing indicator
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 0,
typingContact: true,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
renderTypingBubble: () => (
<TypingBubble color="red" conversationType="direct" phoneNumber="+18005552222" i18n={util.i18n} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## Without newest message
```
const props = {
id: 'conversationId-1',
haveNewest: false,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: 3,
scrollToIndexCounter: 0,
totalUnread: 0,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## Without oldest message
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: false,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 0,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
renderLoadingRow: () => (
<TimelineLoadingRow state="idle" />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>; </div>;
``` ```

View file

@ -1,3 +1,4 @@
import { debounce, isNumber } from 'lodash';
import React from 'react'; import React from 'react';
import { import {
AutoSizer, AutoSizer,
@ -6,24 +7,64 @@ import {
List, List,
} from 'react-virtualized'; } from 'react-virtualized';
import { ScrollDownButton } from './ScrollDownButton';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { PropsActions as MessageActionsType } from './Message'; import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
type PropsData = { const AT_BOTTOM_THRESHOLD = 1;
const NEAR_BOTTOM_THRESHOLD = 15;
const AT_TOP_THRESHOLD = 10;
const LOAD_MORE_THRESHOLD = 30;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
export const LOAD_COUNTDOWN = 2 * 1000;
export type PropsDataType = {
haveNewest: boolean;
haveOldest: boolean;
isLoadingMessages: boolean;
items: Array<string>; items: Array<string>;
loadCountdownStart?: number;
renderItem: (id: string) => JSX.Element; messageHeightChanges: boolean;
oldestUnreadIndex?: number;
resetCounter: number;
scrollToIndex?: number;
scrollToIndexCounter: number;
totalUnread: number;
}; };
type PropsHousekeeping = { type PropsHousekeepingType = {
id: string;
unreadCount?: number;
typingContact?: Object;
i18n: LocalizerType; i18n: LocalizerType;
renderItem: (id: string, actions: Object) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
renderLoadingRow: (id: string) => JSX.Element;
renderTypingBubble: (id: string) => JSX.Element;
}; };
type PropsActions = MessageActionsType & SafetyNumberActionsType; type PropsActionsType = {
clearChangedMessages: (conversationId: string) => unknown;
setLoadCountdownStart: (
conversationId: string,
loadCountdownStart?: number
) => unknown;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
type Props = PropsData & PropsHousekeeping & PropsActions; loadAndScroll: (messageId: string) => unknown;
loadOlderMessages: (messageId: string) => unknown;
loadNewerMessages: (messageId: string) => unknown;
loadNewestMessages: (messageId: string) => unknown;
markMessageRead: (messageId: string) => unknown;
} & MessageActionsType &
SafetyNumberActionsType;
type Props = PropsDataType & PropsHousekeepingType & PropsActionsType;
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5 // from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
type RowRendererParamsType = { type RowRendererParamsType = {
@ -34,37 +75,407 @@ type RowRendererParamsType = {
parent: Object; parent: Object;
style: Object; style: Object;
}; };
type OnScrollParamsType = {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
export class Timeline extends React.PureComponent<Props> { clientWidth: number;
scrollWidth?: number;
scrollLeft?: number;
scrollToColumn?: number;
_hasScrolledToColumnTarget?: boolean;
scrollToRow?: number;
_hasScrolledToRowTarget?: boolean;
};
type VisibleRowsType = {
newest?: {
id: string;
offsetTop: number;
row: number;
};
oldest?: {
id: string;
offsetTop: number;
row: number;
};
};
type State = {
atBottom: boolean;
atTop: boolean;
oneTimeScrollRow?: number;
prevPropScrollToIndex?: number;
prevPropScrollToIndexCounter?: number;
propScrollToIndex?: number;
shouldShowScrollDownButton: boolean;
areUnreadBelowCurrentPosition: boolean;
};
export class Timeline extends React.PureComponent<Props, State> {
public cellSizeCache = new CellMeasurerCache({ public cellSizeCache = new CellMeasurerCache({
defaultHeight: 85, defaultHeight: 64,
fixedWidth: true, fixedWidth: true,
}); });
public mostRecentWidth = 0; public mostRecentWidth = 0;
public mostRecentHeight = 0;
public offsetFromBottom: number | undefined = 0;
public resizeAllFlag = false; public resizeAllFlag = false;
public listRef = React.createRef<any>(); public listRef = React.createRef<any>();
public visibleRows: VisibleRowsType | undefined;
public loadCountdownTimeout: any;
public componentDidUpdate(prevProps: Props) { constructor(props: Props) {
if (this.resizeAllFlag) { super(props);
this.resizeAllFlag = false;
this.cellSizeCache.clearAll(); const { scrollToIndex } = this.props;
this.recomputeRowHeights(); const oneTimeScrollRow = this.getLastSeenIndicatorRow();
} else if (this.props.items !== prevProps.items) {
const index = prevProps.items.length; this.state = {
this.cellSizeCache.clear(index, 0); atBottom: true,
this.recomputeRowHeights(index); atTop: false,
} oneTimeScrollRow,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false,
};
} }
public resizeAll = () => { public static getDerivedStateFromProps(props: Props, state: State): State {
this.resizeAllFlag = false; if (
this.cellSizeCache.clearAll(); isNumber(props.scrollToIndex) &&
(props.scrollToIndex !== state.prevPropScrollToIndex ||
props.scrollToIndexCounter !== state.prevPropScrollToIndexCounter)
) {
return {
...state,
propScrollToIndex: props.scrollToIndex,
prevPropScrollToIndex: props.scrollToIndex,
prevPropScrollToIndexCounter: props.scrollToIndexCounter,
};
}
return state;
}
public getList = () => {
if (!this.listRef) {
return;
}
const { current } = this.listRef;
return current;
}; };
public recomputeRowHeights = (index?: number) => { public getGrid = () => {
if (this.listRef && this.listRef) { const list = this.getList();
this.listRef.current.recomputeRowHeights(index); if (!list) {
return;
} }
return list.Grid;
};
public getScrollContainer = () => {
const grid = this.getGrid();
if (!grid) {
return;
}
return grid._scrollingContainer as HTMLDivElement;
};
public scrollToRow = (row: number) => {
const list = this.getList();
if (!list) {
return;
}
list.scrollToRow(row);
};
public recomputeRowHeights = (row?: number) => {
const list = this.getList();
if (!list) {
return;
}
list.recomputeRowHeights(row);
};
public onHeightOnlyChange = () => {
const grid = this.getGrid();
const scrollContainer = this.getScrollContainer();
if (!grid || !scrollContainer) {
return;
}
if (!isNumber(this.offsetFromBottom)) {
return;
}
const { clientHeight, scrollHeight, scrollTop } = scrollContainer;
const newOffsetFromBottom = Math.max(
0,
scrollHeight - clientHeight - scrollTop
);
const delta = newOffsetFromBottom - this.offsetFromBottom;
grid.scrollToPosition({ scrollTop: scrollContainer.scrollTop + delta });
};
public resizeAll = () => {
this.offsetFromBottom = undefined;
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
const rowCount = this.getRowCount();
this.recomputeRowHeights(rowCount - 1);
};
public onScroll = (data: OnScrollParamsType) => {
// Ignore scroll events generated as react-virtualized recursively scrolls and
// re-measures to get us where we want to go.
if (
isNumber(data.scrollToRow) &&
data.scrollToRow >= 0 &&
!data._hasScrolledToRowTarget
) {
return;
}
// Sometimes react-virtualized ends up with some incorrect math - we've scrolled below
// what should be possible. In this case, we leave everything the same and ask
// react-virtualized to try again. Without this, we'll set atBottom to true and
// pop the user back down to the bottom.
const { clientHeight, scrollHeight, scrollTop } = data;
if (scrollTop + clientHeight > scrollHeight) {
this.resizeAll();
return;
}
this.updateScrollMetrics(data);
this.updateWithVisibleRows();
};
// tslint:disable-next-line member-ordering
public updateScrollMetrics = debounce(
(data: OnScrollParamsType) => {
const { clientHeight, clientWidth, scrollHeight, scrollTop } = data;
if (clientHeight <= 0 || scrollHeight <= 0) {
return;
}
const {
haveNewest,
haveOldest,
id,
setIsNearBottom,
setLoadCountdownStart,
} = this.props;
if (
this.mostRecentHeight &&
clientHeight !== this.mostRecentHeight &&
this.mostRecentWidth &&
clientWidth === this.mostRecentWidth
) {
this.onHeightOnlyChange();
}
// If we've scrolled, we want to reset these
const oneTimeScrollRow = undefined;
const propScrollToIndex = undefined;
this.offsetFromBottom = Math.max(
0,
scrollHeight - clientHeight - scrollTop
);
const atBottom =
haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD;
const isNearBottom =
haveNewest && this.offsetFromBottom <= NEAR_BOTTOM_THRESHOLD;
const atTop = scrollTop <= AT_TOP_THRESHOLD;
const loadCountdownStart = atTop && !haveOldest ? Date.now() : undefined;
if (this.loadCountdownTimeout) {
clearTimeout(this.loadCountdownTimeout);
this.loadCountdownTimeout = null;
}
if (isNumber(loadCountdownStart)) {
this.loadCountdownTimeout = setTimeout(
this.loadOlderMessages,
LOAD_COUNTDOWN
);
}
if (loadCountdownStart !== this.props.loadCountdownStart) {
setLoadCountdownStart(id, loadCountdownStart);
}
setIsNearBottom(id, isNearBottom);
this.setState({
atBottom,
atTop,
oneTimeScrollRow,
propScrollToIndex,
});
},
50,
{ maxWait: 50 }
);
public updateVisibleRows = () => {
let newest;
let oldest;
const scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
return;
}
if (scrollContainer.clientHeight === 0) {
return;
}
const visibleTop = scrollContainer.scrollTop;
const visibleBottom = visibleTop + scrollContainer.clientHeight;
const innerScrollContainer = scrollContainer.children[0];
if (!innerScrollContainer) {
return;
}
const { children } = innerScrollContainer;
for (let i = children.length - 1; i >= 0; i -= 1) {
const child = children[i] as HTMLDivElement;
const { id, offsetTop, offsetHeight } = child;
if (!id) {
continue;
}
const bottom = offsetTop + offsetHeight;
if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) {
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
newest = { offsetTop, row, id };
break;
}
}
const max = children.length;
for (let i = 0; i < max; i += 1) {
const child = children[i] as HTMLDivElement;
const { offsetTop, id } = child;
if (!id) {
continue;
}
if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) {
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
oldest = { offsetTop, row, id };
break;
}
}
this.visibleRows = { newest, oldest };
};
// tslint:disable-next-line member-ordering cyclomatic-complexity
public updateWithVisibleRows = debounce(
() => {
const {
unreadCount,
haveNewest,
isLoadingMessages,
items,
loadNewerMessages,
markMessageRead,
} = this.props;
if (!items || items.length < 1) {
return;
}
this.updateVisibleRows();
if (!this.visibleRows) {
return;
}
const { newest } = this.visibleRows;
if (!newest || !newest.id) {
return;
}
markMessageRead(newest.id);
const rowCount = this.getRowCount();
const lastId = items[items.length - 1];
if (
!isLoadingMessages &&
!haveNewest &&
newest.row > rowCount - LOAD_MORE_THRESHOLD
) {
loadNewerMessages(lastId);
}
const lastIndex = items.length - 1;
const lastItemRow = this.fromItemIndexToRow(lastIndex);
const areUnreadBelowCurrentPosition = Boolean(
isNumber(unreadCount) &&
unreadCount > 0 &&
(!haveNewest || newest.row < lastItemRow)
);
const shouldShowScrollDownButton = Boolean(
!haveNewest ||
areUnreadBelowCurrentPosition ||
newest.row < rowCount - SCROLL_DOWN_BUTTON_THRESHOLD
);
this.setState({
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
});
},
500,
{ maxWait: 500 }
);
public loadOlderMessages = () => {
const {
haveOldest,
isLoadingMessages,
items,
loadOlderMessages,
} = this.props;
if (this.loadCountdownTimeout) {
clearTimeout(this.loadCountdownTimeout);
this.loadCountdownTimeout = null;
}
if (isLoadingMessages || haveOldest || !items || items.length < 1) {
return;
}
const oldestId = items[0];
loadOlderMessages(oldestId);
}; };
public rowRenderer = ({ public rowRenderer = ({
@ -73,8 +484,62 @@ export class Timeline extends React.PureComponent<Props> {
parent, parent,
style, style,
}: RowRendererParamsType) => { }: RowRendererParamsType) => {
const { items, renderItem } = this.props; const {
const messageId = items[index]; id,
haveOldest,
items,
renderItem,
renderLoadingRow,
renderLastSeenIndicator,
renderTypingBubble,
} = this.props;
const row = index;
const oldestUnreadRow = this.getLastSeenIndicatorRow();
const typingBubbleRow = this.getTypingBubbleRow();
let rowContents;
if (!haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={style}>
{renderLoadingRow(id)}
</div>
);
} else if (oldestUnreadRow === row) {
rowContents = (
<div data-row={row} style={style}>
{renderLastSeenIndicator(id)}
</div>
);
} else if (typingBubbleRow === row) {
rowContents = (
<div
data-row={row}
className="module-timeline__message-container"
style={style}
>
{renderTypingBubble(id)}
</div>
);
} else {
const itemIndex = this.fromRowToItemIndex(row);
if (typeof itemIndex !== 'number') {
throw new Error(
`Attempted to render item with undefined index - row ${row}`
);
}
const messageId = items[itemIndex];
rowContents = (
<div
id={messageId}
data-row={row}
className="module-timeline__message-container"
style={style}
>
{renderItem(messageId, this.props)}
</div>
);
}
return ( return (
<CellMeasurer <CellMeasurer
@ -85,16 +550,277 @@ export class Timeline extends React.PureComponent<Props> {
rowIndex={index} rowIndex={index}
width={this.mostRecentWidth} width={this.mostRecentWidth}
> >
<div className="module-timeline__message-container" style={style}> {rowContents}
{renderItem(messageId)}
</div>
</CellMeasurer> </CellMeasurer>
); );
}; };
public render() { public fromItemIndexToRow(index: number) {
const { haveOldest, oldestUnreadIndex } = this.props;
let addition = 0;
if (!haveOldest) {
addition += 1;
}
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
addition += 1;
}
return index + addition;
}
public getRowCount() {
const { haveOldest, oldestUnreadIndex, typingContact } = this.props;
const { items } = this.props; const { items } = this.props;
if (!items || items.length < 1) {
return 0;
}
let extraRows = 0;
if (!haveOldest) {
extraRows += 1;
}
if (isNumber(oldestUnreadIndex)) {
extraRows += 1;
}
if (typingContact) {
extraRows += 1;
}
return items.length + extraRows;
}
public fromRowToItemIndex(row: number): number | undefined {
const { haveOldest, items } = this.props;
let subtraction = 0;
if (!haveOldest) {
subtraction += 1;
}
const oldestUnreadRow = this.getLastSeenIndicatorRow();
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {
subtraction += 1;
}
const index = row - subtraction;
if (index < 0 || index >= items.length) {
return;
}
return index;
}
public getLastSeenIndicatorRow() {
const { oldestUnreadIndex } = this.props;
if (!isNumber(oldestUnreadIndex)) {
return;
}
return this.fromItemIndexToRow(oldestUnreadIndex) - 1;
}
public getTypingBubbleRow() {
const { items } = this.props;
if (!items || items.length < 0) {
return;
}
const last = items.length - 1;
return this.fromItemIndexToRow(last) + 1;
}
public onScrollToMessage = (messageId: string) => {
const { isLoadingMessages, items, loadAndScroll } = this.props;
const index = items.findIndex(item => item === messageId);
if (index >= 0) {
const row = this.fromItemIndexToRow(index);
this.setState({
oneTimeScrollRow: row,
});
}
if (!isLoadingMessages) {
loadAndScroll(messageId);
}
};
public scrollToBottom = () => {
this.setState({
propScrollToIndex: undefined,
oneTimeScrollRow: undefined,
atBottom: true,
});
};
public onClickScrollDownButton = () => {
const {
haveNewest,
isLoadingMessages,
items,
loadNewestMessages,
} = this.props;
const lastId = items[items.length - 1];
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
if (!this.visibleRows) {
if (haveNewest) {
this.scrollToBottom();
} else if (!isLoadingMessages) {
loadNewestMessages(lastId);
}
return;
}
const { newest } = this.visibleRows;
if (
newest &&
isNumber(lastSeenIndicatorRow) &&
newest.row < lastSeenIndicatorRow
) {
this.setState({
oneTimeScrollRow: lastSeenIndicatorRow,
});
} else if (haveNewest) {
this.scrollToBottom();
} else if (!isLoadingMessages) {
loadNewestMessages(lastId);
}
};
public componentDidUpdate(prevProps: Props) {
const {
id,
clearChangedMessages,
items,
messageHeightChanges,
oldestUnreadIndex,
resetCounter,
scrollToIndex,
typingContact,
} = this.props;
// There are a number of situations which can necessitate that we drop our row height
// cache and start over. It can cause the scroll position to do weird things, so we
// try to minimize those situations. In some cases we could reset a smaller set
// of cached row data, but we currently don't have an API for that. We'd need to
// create it.
if (
!prevProps.items ||
prevProps.items.length === 0 ||
resetCounter !== prevProps.resetCounter
) {
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
this.setState({
oneTimeScrollRow,
atBottom: true,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
});
if (prevProps.items && prevProps.items.length > 0) {
this.resizeAll();
}
return;
} else if (!typingContact && prevProps.typingContact) {
this.resizeAll();
} else if (oldestUnreadIndex !== prevProps.oldestUnreadIndex) {
this.resizeAll();
} else if (
items &&
items.length > 0 &&
prevProps.items &&
prevProps.items.length > 0 &&
items !== prevProps.items
) {
if (this.state.atTop) {
const oldFirstIndex = 0;
const oldFirstId = prevProps.items[oldFirstIndex];
const newIndex = items.findIndex(item => item === oldFirstId);
if (newIndex < 0) {
this.resizeAll();
return;
}
const newRow = this.fromItemIndexToRow(newIndex);
this.resizeAll();
this.setState({ oneTimeScrollRow: newRow });
} else {
const oldLastIndex = prevProps.items.length - 1;
const oldLastId = prevProps.items[oldLastIndex];
const newIndex = items.findIndex(item => item === oldLastId);
if (newIndex < 0) {
this.resizeAll();
return;
}
const indexDelta = newIndex - oldLastIndex;
// If we've just added to the end of the list, then the index of the last id's
// index won't have changed, and we can rely on List's detection that items is
// different for the necessary re-render.
if (indexDelta !== 0) {
this.resizeAll();
}
}
} else if (messageHeightChanges) {
this.resizeAll();
clearChangedMessages(id);
} else if (this.resizeAllFlag) {
this.resizeAll();
}
}
public getScrollTarget = () => {
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
const rowCount = this.getRowCount();
const targetMessage = isNumber(propScrollToIndex)
? this.fromItemIndexToRow(propScrollToIndex)
: undefined;
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
if (isNumber(targetMessage)) {
return targetMessage;
}
if (isNumber(oneTimeScrollRow)) {
return oneTimeScrollRow;
}
return scrollToBottom;
};
public render() {
const { i18n, id, items } = this.props;
const {
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
} = this.state;
if (!items || items.length < 1) {
return null;
}
const rowCount = this.getRowCount();
const scrollToIndex = this.getScrollTarget();
return ( return (
<div className="module-timeline"> <div className="module-timeline">
<AutoSizer> <AutoSizer>
@ -103,26 +829,41 @@ export class Timeline extends React.PureComponent<Props> {
this.resizeAllFlag = true; this.resizeAllFlag = true;
setTimeout(this.resizeAll, 0); setTimeout(this.resizeAll, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
} }
this.mostRecentWidth = width; this.mostRecentWidth = width;
this.mostRecentHeight = height;
return ( return (
<List <List
deferredMeasurementCache={this.cellSizeCache} deferredMeasurementCache={this.cellSizeCache}
height={height} height={height}
// This also registers us with parent InfiniteLoader onScroll={this.onScroll as any}
// onRowsRendered={onRowsRendered} overscanRowCount={10}
overscanRowCount={0}
ref={this.listRef} ref={this.listRef}
rowCount={items.length} rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight} rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer} rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
width={width} width={width}
/> />
); );
}} }}
</AutoSizer> </AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div> </div>
); );
} }

View file

@ -1,3 +1,51 @@
### A plain message
```jsx ```jsx
const item = {} < TimelineItem; const item = {
type: 'message',
data: {
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
text: '🔥',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### A notification
```jsx
const item = {
type: 'timerNotification',
data: {
type: 'fromOther',
phoneNumber: '(202) 555-0000',
timespan: '1 hour',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### Unknown type
```jsx
const item = {
type: 'random',
data: {
somethin: 'somethin',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### Missing itme
```jsx
<TimelineItem item={null} i18n={util.i18n} />
``` ```

View file

@ -6,6 +6,11 @@ import {
PropsActions as MessageActionsType, PropsActions as MessageActionsType,
PropsData as MessageProps, PropsData as MessageProps,
} from './Message'; } from './Message';
import {
PropsActions as UnsupportedMessageActionsType,
PropsData as UnsupportedMessageProps,
UnsupportedMessage,
} from './UnsupportedMessage';
import { import {
PropsData as TimerNotificationProps, PropsData as TimerNotificationProps,
TimerNotification, TimerNotification,
@ -29,6 +34,10 @@ type MessageType = {
type: 'message'; type: 'message';
data: MessageProps; data: MessageProps;
}; };
type UnsupportedMessageType = {
type: 'unsupportedMessage';
data: UnsupportedMessageProps;
};
type TimerNotificationType = { type TimerNotificationType = {
type: 'timerNotification'; type: 'timerNotification';
data: TimerNotificationProps; data: TimerNotificationProps;
@ -49,22 +58,26 @@ type ResetSessionNotificationType = {
type: 'resetSessionNotification'; type: 'resetSessionNotification';
data: null; data: null;
}; };
export type TimelineItemType =
| MessageType
| UnsupportedMessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
type PropsData = { type PropsData = {
item: item?: TimelineItemType;
| MessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
}; };
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
}; };
type PropsActions = MessageActionsType & SafetyNumberActionsType; type PropsActions = MessageActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
type Props = PropsData & PropsHousekeeping & PropsActions; type Props = PropsData & PropsHousekeeping & PropsActions;
@ -73,12 +86,18 @@ export class TimelineItem extends React.PureComponent<Props> {
const { item, i18n } = this.props; const { item, i18n } = this.props;
if (!item) { if (!item) {
throw new Error('TimelineItem: Item was not provided!'); // tslint:disable-next-line:no-console
console.warn('TimelineItem: item provided was falsey');
return null;
} }
if (item.type === 'message') { if (item.type === 'message') {
return <Message {...this.props} {...item.data} i18n={i18n} />; return <Message {...this.props} {...item.data} i18n={i18n} />;
} }
if (item.type === 'unsupportedMessage') {
return <UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'timerNotification') { if (item.type === 'timerNotification') {
return <TimerNotification {...this.props} {...item.data} i18n={i18n} />; return <TimerNotification {...this.props} {...item.data} i18n={i18n} />;
} }

View file

@ -0,0 +1,28 @@
### Idle
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<TimelineLoadingRow state="idle" />
</util.ConversationContext>
```
### Countdown
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<TimelineLoadingRow
state="countdown"
duration={30000}
expiresAt={Date.now() + 20000}
onComplete={() => console.log('onComplete')}
/>
</util.ConversationContext>
```
### Loading
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<TimelineLoadingRow state="loading" />
</util.ConversationContext>
```

View file

@ -0,0 +1,48 @@
import React from 'react';
import { isNumber } from 'lodash';
import { Countdown } from '../Countdown';
import { Spinner } from '../Spinner';
export type STATE_ENUM = 'idle' | 'countdown' | 'loading';
type Props = {
state: STATE_ENUM;
duration?: number;
expiresAt?: number;
onComplete?: () => unknown;
};
const FAKE_DURATION = 1000;
export class TimelineLoadingRow extends React.PureComponent<Props> {
public renderContents() {
const { state, duration, expiresAt, onComplete } = this.props;
if (state === 'idle') {
const fakeExpiresAt = Date.now() - FAKE_DURATION;
return <Countdown duration={FAKE_DURATION} expiresAt={fakeExpiresAt} />;
} else if (
state === 'countdown' &&
isNumber(duration) &&
isNumber(expiresAt)
) {
return (
<Countdown
duration={duration}
expiresAt={expiresAt}
onComplete={onComplete}
/>
);
}
return <Spinner size="24" svgSize="small" direction="on-background" />;
}
public render() {
return (
<div className="module-timeline-loading-row">{this.renderContents()}</div>
);
}
}

View file

@ -5,8 +5,6 @@ import { ContactName } from './ContactName';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
export type PropsData = { export type PropsData = {
type: 'fromOther' | 'fromMe' | 'fromSync'; type: 'fromOther' | 'fromMe' | 'fromSync';
phoneNumber: string; phoneNumber: string;
@ -63,7 +61,9 @@ export class TimerNotification extends React.Component<Props> {
? i18n('disappearingMessagesDisabled') ? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]); : i18n('timerSetOnSync', [timespan]);
default: default:
throw missingCaseError(type); console.warn('TimerNotification: unsupported type provided:', type);
return null;
} }
} }

View file

@ -19,7 +19,7 @@ function getDecember1159() {
} }
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -28,8 +28,8 @@ function getDecember1159() {
text="500ms ago - all below 1 minute are 'now'" text="500ms ago - all below 1 minute are 'now'"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -38,8 +38,8 @@ function getDecember1159() {
text="Five seconds ago" text="Five seconds ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -48,8 +48,8 @@ function getDecember1159() {
text="30 seconds ago" text="30 seconds ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -58,8 +58,8 @@ function getDecember1159() {
text="One minute ago - in minutes" text="One minute ago - in minutes"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -68,8 +68,8 @@ function getDecember1159() {
text="30 minutes ago" text="30 minutes ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -78,8 +78,8 @@ function getDecember1159() {
text="45 minutes ago (used to round up to 1 hour with moment)" text="45 minutes ago (used to round up to 1 hour with moment)"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -88,8 +88,8 @@ function getDecember1159() {
text="One hour ago - in hours" text="One hour ago - in hours"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -98,8 +98,8 @@ function getDecember1159() {
text="12:01am today" text="12:01am today"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -108,8 +108,8 @@ function getDecember1159() {
text="11:59pm yesterday - adds day name" text="11:59pm yesterday - adds day name"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -118,8 +118,8 @@ function getDecember1159() {
text="24 hours ago" text="24 hours ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -128,8 +128,8 @@ function getDecember1159() {
text="Two days ago" text="Two days ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -138,8 +138,8 @@ function getDecember1159() {
text="Seven days ago - adds month" text="Seven days ago - adds month"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -148,8 +148,8 @@ function getDecember1159() {
text="Thirty days ago" text="Thirty days ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -158,8 +158,8 @@ function getDecember1159() {
text="January 1st at 12:01am" text="January 1st at 12:01am"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -168,8 +168,8 @@ function getDecember1159() {
text="December 31st at 11:59pm - adds year" text="December 31st at 11:59pm - adds year"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<Message <Message
direction="incoming" direction="incoming"
status="delivered" status="delivered"
@ -178,6 +178,6 @@ function getDecember1159() {
text="One year ago" text="One year ago"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
</util.ConversationContext>; </util.ConversationContext>;
``` ```

View file

@ -2,12 +2,12 @@
```jsx ```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<TypingBubble conversationType="direct" i18n={util.i18n} /> <TypingBubble conversationType="direct" i18n={util.i18n} />
</li> </div>
<li> <div className="module-message-container">
<TypingBubble color="teal" conversationType="direct" i18n={util.i18n} /> <TypingBubble color="teal" conversationType="direct" i18n={util.i18n} />
</li> </div>
</util.ConversationContext> </util.ConversationContext>
``` ```
@ -15,24 +15,24 @@
```jsx ```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <div className="module-message-container">
<TypingBubble color="red" conversationType="group" i18n={util.i18n} /> <TypingBubble color="red" conversationType="group" i18n={util.i18n} />
</li> </div>
<li> <div className="module-message-container">
<TypingBubble <TypingBubble
color="purple" color="purple"
authorName="First Last" authorName="First Last"
conversationType="group" conversationType="group"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
<li> <div className="module-message-container">
<TypingBubble <TypingBubble
avatarPath={util.gifObjectUrl} avatarPath={util.gifObjectUrl}
color="blue" color="blue"
conversationType="group" conversationType="group"
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </div>
</util.ConversationContext> </util.ConversationContext>
``` ```

View file

@ -9,14 +9,14 @@ import { LocalizerType } from '../../types/Util';
interface Props { interface Props {
avatarPath?: string; avatarPath?: string;
color: string; color: string;
name: string; name?: string;
phoneNumber: string; phoneNumber: string;
profileName: string; profileName?: string;
conversationType: string; conversationType: 'group' | 'direct';
i18n: LocalizerType; i18n: LocalizerType;
} }
export class TypingBubble extends React.Component<Props> { export class TypingBubble extends React.PureComponent<Props> {
public renderAvatar() { public renderAvatar() {
const { const {
avatarPath, avatarPath,
@ -49,10 +49,17 @@ export class TypingBubble extends React.Component<Props> {
} }
public render() { public render() {
const { i18n, color } = this.props; const { i18n, color, conversationType } = this.props;
const isGroup = conversationType === 'group';
return ( return (
<div className={classNames('module-message', 'module-message--incoming')}> <div
className={classNames(
'module-message',
'module-message--incoming',
isGroup ? 'module-message--group' : null
)}
>
<div <div
className={classNames( className={classNames(
'module-message__container', 'module-message__container',

View file

@ -18,14 +18,14 @@ export type PropsData = {
contact: ContactType; contact: ContactType;
}; };
type PropsHousekeeping = {
i18n: LocalizerType;
};
export type PropsActions = { export type PropsActions = {
downloadNewVersion: () => unknown; downloadNewVersion: () => unknown;
}; };
type PropsHousekeeping = {
i18n: LocalizerType;
};
type Props = PropsData & PropsHousekeeping & PropsActions; type Props = PropsData & PropsHousekeeping & PropsActions;
export class UnsupportedMessage extends React.Component<Props> { export class UnsupportedMessage extends React.Component<Props> {

View file

@ -18,7 +18,7 @@ export function renderAvatar({
contact: ContactType; contact: ContactType;
i18n: LocalizerType; i18n: LocalizerType;
size: number; size: number;
direction?: string; direction?: 'outgoing' | 'incoming';
}) { }) {
const { avatar } = contact; const { avatar } = contact;

View file

@ -1,4 +1,13 @@
export function getMessageModel(attributes: any) { export function getSearchResultsProps(attributes: any) {
// @ts-ignore // @ts-ignore
return new window.Whisper.Message(attributes); const model = new window.Whisper.Message(attributes);
return model.getPropsForSearchResult();
}
export function getBubbleProps(attributes: any) {
// @ts-ignore
const model = new window.Whisper.Message(attributes);
return model.getPropsForBubble();
} }

View file

@ -1,6 +1,14 @@
import { AnyAction } from 'redux'; import {
import { omit } from 'lodash'; difference,
fromPairs,
intersection,
omit,
orderBy,
pick,
uniq,
values,
without,
} from 'lodash';
import { trigger } from '../../shims/events'; import { trigger } from '../../shims/events';
import { NoopActionType } from './noop'; import { NoopActionType } from './noop';
@ -48,29 +56,65 @@ export type ConversationType = {
lastUpdated: number; lastUpdated: number;
unreadCount: number; unreadCount: number;
isSelected: boolean; isSelected: boolean;
isTyping: boolean; typingContact?: {
avatarPath?: string;
color: string;
name?: string;
phoneNumber: string;
profileName?: string;
};
}; };
export type ConversationLookupType = { export type ConversationLookupType = {
[key: string]: ConversationType; [key: string]: ConversationType;
}; };
export type MessageType = { export type MessageType = {
id: string; id: string;
conversationId: string;
source: string;
type: 'incoming' | 'outgoing' | 'group' | 'keychange' | 'verified-change';
quote?: { author: string };
received_at: number;
hasSignalAccount?: boolean;
// No need to go beyond this; unused at this stage, since this goes into
// a reducer still in plain JavaScript and comes out well-formed
}; };
type MessagePointerType = {
id: string;
received_at: number;
};
type MessageMetricsType = {
newest?: MessagePointerType;
oldest?: MessagePointerType;
oldestUnread?: MessagePointerType;
totalUnread: number;
};
export type MessageLookupType = { export type MessageLookupType = {
[key: string]: MessageType; [key: string]: MessageType;
}; };
export type ConversationMessageType = { export type ConversationMessageType = {
// And perhaps this could be part of our ConversationType? What if we moved all the selectors as part of this set of changes? heightChangeMessageIds: Array<string>;
// We have the infrastructure for it now... isLoadingMessages: boolean;
messages: Array<string>; isNearBottom?: boolean;
loadCountdownStart?: number;
messageIds: Array<string>;
metrics: MessageMetricsType;
resetCounter: number;
scrollToMessageId?: string;
scrollToMessageCounter: number;
}; };
export type MessagesByConversationType = { export type MessagesByConversationType = {
[key: string]: ConversationMessageType; [key: string]: ConversationMessageType | null;
}; };
export type ConversationsStateType = { export type ConversationsStateType = {
conversationLookup: ConversationLookupType; conversationLookup: ConversationLookupType;
selectedConversation?: string; selectedConversation?: string;
selectedMessage?: string;
selectedMessageCounter: number;
showArchived: boolean; showArchived: boolean;
// Note: it's very important that both of these locations are always kept up to date // Note: it's very important that both of these locations are always kept up to date
@ -100,15 +144,91 @@ type ConversationRemovedActionType = {
id: string; id: string;
}; };
}; };
type ConversationUnloadedActionType = {
type: 'CONVERSATION_UNLOADED';
payload: {
id: string;
};
};
export type RemoveAllConversationsActionType = { export type RemoveAllConversationsActionType = {
type: 'CONVERSATIONS_REMOVE_ALL'; type: 'CONVERSATIONS_REMOVE_ALL';
payload: null; payload: null;
}; };
export type MessageExpiredActionType = { export type MessageChangedActionType = {
type: 'MESSAGE_EXPIRED'; type: 'MESSAGE_CHANGED';
payload: { payload: {
id: string; id: string;
conversationId: string; conversationId: string;
data: MessageType;
};
};
export type MessageDeletedActionType = {
type: 'MESSAGE_DELETED';
payload: {
id: string;
conversationId: string;
};
};
export type MessagesAddedActionType = {
type: 'MESSAGES_ADDED';
payload: {
conversationId: string;
messages: Array<MessageType>;
isNewMessage: boolean;
isFocused: boolean;
};
};
export type MessagesResetActionType = {
type: 'MESSAGES_RESET';
payload: {
conversationId: string;
messages: Array<MessageType>;
metrics: MessageMetricsType;
scrollToMessageId?: string;
};
};
export type SetMessagesLoadingActionType = {
type: 'SET_MESSAGES_LOADING';
payload: {
conversationId: string;
isLoadingMessages: boolean;
};
};
export type SetLoadCountdownStartActionType = {
type: 'SET_LOAD_COUNTDOWN_START';
payload: {
conversationId: string;
loadCountdownStart?: number;
};
};
export type SetIsNearBottomActionType = {
type: 'SET_NEAR_BOTTOM';
payload: {
conversationId: string;
isNearBottom: boolean;
};
};
export type ScrollToMessageActionType = {
type: 'SCROLL_TO_MESSAGE';
payload: {
conversationId: string;
messageId: string;
};
};
export type ClearChangedMessagesActionType = {
type: 'CLEAR_CHANGED_MESSAGES';
payload: {
conversationId: string;
};
};
export type ClearSelectedMessageActionType = {
type: 'CLEAR_SELECTED_MESSAGE';
payload: null;
};
export type ClearUnreadMetricsActionType = {
type: 'CLEAR_UNREAD_METRICS';
payload: {
conversationId: string;
}; };
}; };
export type SelectedConversationChangedActionType = { export type SelectedConversationChangedActionType = {
@ -128,14 +248,24 @@ type ShowArchivedConversationsActionType = {
}; };
export type ConversationActionType = export type ConversationActionType =
| AnyAction
| ConversationAddedActionType | ConversationAddedActionType
| ConversationChangedActionType | ConversationChangedActionType
| ConversationRemovedActionType | ConversationRemovedActionType
| ConversationUnloadedActionType
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| MessageExpiredActionType | MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
| MessagesResetActionType
| SetMessagesLoadingActionType
| SetIsNearBottomActionType
| SetLoadCountdownStartActionType
| ClearChangedMessagesActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType | SelectedConversationChangedActionType
| MessageExpiredActionType | MessageDeletedActionType
| SelectedConversationChangedActionType | SelectedConversationChangedActionType
| ShowInboxActionType | ShowInboxActionType
| ShowArchivedConversationsActionType; | ShowArchivedConversationsActionType;
@ -146,8 +276,19 @@ export const actions = {
conversationAdded, conversationAdded,
conversationChanged, conversationChanged,
conversationRemoved, conversationRemoved,
conversationUnloaded,
removeAllConversations, removeAllConversations,
messageExpired, messageDeleted,
messageChanged,
messagesAdded,
messagesReset,
setMessagesLoading,
setLoadCountdownStart,
setIsNearBottom,
clearChangedMessages,
clearSelectedMessage,
clearUnreadMetrics,
scrollToMessage,
openConversationInternal, openConversationInternal,
openConversationExternal, openConversationExternal,
showInbox, showInbox,
@ -186,6 +327,14 @@ function conversationRemoved(id: string): ConversationRemovedActionType {
}, },
}; };
} }
function conversationUnloaded(id: string): ConversationUnloadedActionType {
return {
type: 'CONVERSATION_UNLOADED',
payload: {
id,
},
};
}
function removeAllConversations(): RemoveAllConversationsActionType { function removeAllConversations(): RemoveAllConversationsActionType {
return { return {
type: 'CONVERSATIONS_REMOVE_ALL', type: 'CONVERSATIONS_REMOVE_ALL',
@ -193,22 +342,144 @@ function removeAllConversations(): RemoveAllConversationsActionType {
}; };
} }
function messageExpired( function messageChanged(
id: string,
conversationId: string,
data: MessageType
): MessageChangedActionType {
return {
type: 'MESSAGE_CHANGED',
payload: {
id,
conversationId,
data,
},
};
}
function messageDeleted(
id: string, id: string,
conversationId: string conversationId: string
): MessageExpiredActionType { ): MessageDeletedActionType {
return { return {
type: 'MESSAGE_EXPIRED', type: 'MESSAGE_DELETED',
payload: { payload: {
id, id,
conversationId, conversationId,
}, },
}; };
} }
function messagesAdded(
conversationId: string,
messages: Array<MessageType>,
isNewMessage: boolean,
isFocused: boolean
): MessagesAddedActionType {
return {
type: 'MESSAGES_ADDED',
payload: {
conversationId,
messages,
isNewMessage,
isFocused,
},
};
}
function messagesReset(
conversationId: string,
messages: Array<MessageType>,
metrics: MessageMetricsType,
scrollToMessageId?: string
): MessagesResetActionType {
return {
type: 'MESSAGES_RESET',
payload: {
conversationId,
messages,
metrics,
scrollToMessageId,
},
};
}
function setMessagesLoading(
conversationId: string,
isLoadingMessages: boolean
): SetMessagesLoadingActionType {
return {
type: 'SET_MESSAGES_LOADING',
payload: {
conversationId,
isLoadingMessages,
},
};
}
function setLoadCountdownStart(
conversationId: string,
loadCountdownStart?: number
): SetLoadCountdownStartActionType {
return {
type: 'SET_LOAD_COUNTDOWN_START',
payload: {
conversationId,
loadCountdownStart,
},
};
}
function setIsNearBottom(
conversationId: string,
isNearBottom: boolean
): SetIsNearBottomActionType {
return {
type: 'SET_NEAR_BOTTOM',
payload: {
conversationId,
isNearBottom,
},
};
}
function clearChangedMessages(
conversationId: string
): ClearChangedMessagesActionType {
return {
type: 'CLEAR_CHANGED_MESSAGES',
payload: {
conversationId,
},
};
}
function clearSelectedMessage(): ClearSelectedMessageActionType {
return {
type: 'CLEAR_SELECTED_MESSAGE',
payload: null,
};
}
function clearUnreadMetrics(
conversationId: string
): ClearUnreadMetricsActionType {
return {
type: 'CLEAR_UNREAD_METRICS',
payload: {
conversationId,
},
};
}
function scrollToMessage(
conversationId: string,
messageId: string
): ScrollToMessageActionType {
return {
type: 'SCROLL_TO_MESSAGE',
payload: {
conversationId,
messageId,
},
};
}
// Note: we need two actions here to simplify. Operations outside of the left pane can // Note: we need two actions here to simplify. Operations outside of the left pane can
// trigger an 'openConversation' so we go through Whisper.events for all conversation // trigger an 'openConversation' so we go through Whisper.events for all
// selection. // conversation selection. Internal just triggers the Whisper.event, and External
// makes the changes to the store.
function openConversationInternal( function openConversationInternal(
id: string, id: string,
messageId?: string messageId?: string
@ -251,12 +522,24 @@ function showArchivedConversations() {
function getEmptyState(): ConversationsStateType { function getEmptyState(): ConversationsStateType {
return { return {
conversationLookup: {}, conversationLookup: {},
showArchived: false,
messagesLookup: {},
messagesByConversation: {}, messagesByConversation: {},
messagesLookup: {},
selectedMessageCounter: 0,
showArchived: false,
}; };
} }
function hasMessageHeightChanged(
message: MessageType,
previous: MessageType
): Boolean {
return (
Boolean(message.hasSignalAccount || previous.hasSignalAccount) &&
message.hasSignalAccount !== previous.hasSignalAccount
);
}
// tslint:disable-next-line cyclomatic-complexity max-func-body-length
export function reducer( export function reducer(
state: ConversationsStateType = getEmptyState(), state: ConversationsStateType = getEmptyState(),
action: ConversationActionType action: ConversationActionType
@ -322,11 +605,421 @@ export function reducer(
conversationLookup: omit(conversationLookup, [id]), conversationLookup: omit(conversationLookup, [id]),
}; };
} }
if (action.type === 'CONVERSATION_UNLOADED') {
const { payload } = action;
const { id } = payload;
const existingConversation = state.messagesByConversation[id];
if (!existingConversation) {
return state;
}
const { messageIds } = existingConversation;
return {
...state,
messagesLookup: omit(state.messagesLookup, messageIds),
messagesByConversation: omit(state.messagesByConversation, [id]),
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') { if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState(); return getEmptyState();
} }
if (action.type === 'MESSAGE_EXPIRED') { if (action.type === 'MESSAGE_CHANGED') {
// noop - for now this is only important for search const { id, conversationId, data } = action.payload;
const existingConversation = state.messagesByConversation[conversationId];
// We don't keep track of messages unless their conversation is loaded...
if (!existingConversation) {
return state;
}
// ...and we've already loaded that message once
const existingMessage = state.messagesLookup[id];
if (!existingMessage) {
return state;
}
// Check for changes which could affect height - that's why we need this
// heightChangeMessageIds field. It tells Timeline to recalculate all of its heights
const hasHeightChanged = hasMessageHeightChanged(data, existingMessage);
const { heightChangeMessageIds } = existingConversation;
const updatedChanges = hasHeightChanged
? uniq([...heightChangeMessageIds, id])
: heightChangeMessageIds;
return {
...state,
messagesLookup: {
...state.messagesLookup,
[id]: data,
},
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...existingConversation,
heightChangeMessageIds: updatedChanges,
},
},
};
}
if (action.type === 'MESSAGES_RESET') {
const {
conversationId,
messages,
metrics,
scrollToMessageId,
} = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
const resetCounter = existingConversation
? existingConversation.resetCounter + 1
: 0;
const sorted = orderBy(messages, ['received_at'], ['ASC']);
const messageIds = sorted.map(message => message.id);
const lookup = fromPairs(messages.map(message => [message.id, message]));
return {
...state,
selectedMessage: scrollToMessageId,
selectedMessageCounter: state.selectedMessageCounter + 1,
messagesLookup: {
...messagesLookup,
...lookup,
},
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
isLoadingMessages: false,
scrollToMessageId,
scrollToMessageCounter: 0,
messageIds,
metrics,
resetCounter,
heightChangeMessageIds: [],
},
},
};
}
if (action.type === 'SET_MESSAGES_LOADING') {
const { payload } = action;
const { conversationId, isLoadingMessages } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
loadCountdownStart: undefined,
isLoadingMessages,
},
},
};
}
if (action.type === 'SET_LOAD_COUNTDOWN_START') {
const { payload } = action;
const { conversationId, loadCountdownStart } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
loadCountdownStart,
},
},
};
}
if (action.type === 'SET_NEAR_BOTTOM') {
const { payload } = action;
const { conversationId, isNearBottom } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
isNearBottom,
},
},
};
}
if (action.type === 'SCROLL_TO_MESSAGE') {
const { payload } = action;
const { conversationId, messageId } = payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
if (!messagesLookup[messageId]) {
return state;
}
if (!existingConversation.messageIds.includes(messageId)) {
return state;
}
return {
...state,
selectedMessage: messageId,
selectedMessageCounter: state.selectedMessageCounter + 1,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
isLoadingMessages: false,
scrollToMessageId: messageId,
scrollToMessageCounter:
existingConversation.scrollToMessageCounter + 1,
},
},
};
}
if (action.type === 'MESSAGE_DELETED') {
const { id, conversationId } = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
// Assuming that we always have contiguous groups of messages in memory, the removal
// of one message at one end of our message set be replaced with the message right
// next to it.
const oldIds = existingConversation.messageIds;
let { newest, oldest } = existingConversation.metrics;
if (oldIds.length > 1) {
const firstId = oldIds[0];
const lastId = oldIds[oldIds.length - 1];
if (oldest && oldest.id === firstId && firstId === id) {
const second = messagesLookup[oldIds[1]];
oldest = second ? pick(second, ['id', 'received_at']) : undefined;
}
if (newest && newest.id === lastId && lastId === id) {
const penultimate = messagesLookup[oldIds[oldIds.length - 2]];
newest = penultimate
? pick(penultimate, ['id', 'received_at'])
: undefined;
}
}
// Removing it from our caches
const messageIds = without(existingConversation.messageIds, id);
const heightChangeMessageIds = without(
existingConversation.heightChangeMessageIds,
id
);
return {
...state,
messagesLookup: omit(messagesLookup, id),
messagesByConversation: {
[conversationId]: {
...existingConversation,
messageIds,
heightChangeMessageIds,
metrics: {
...existingConversation.metrics,
oldest,
newest,
},
},
},
};
}
if (action.type === 'MESSAGES_ADDED') {
const {
conversationId,
isFocused,
isNewMessage,
messages,
} = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
let {
newest,
oldest,
oldestUnread,
totalUnread,
} = existingConversation.metrics;
const existingTotal = existingConversation.messageIds.length;
if (isNewMessage && existingTotal > 0) {
const lastMessageId = existingConversation.messageIds[existingTotal - 1];
// If our messages in memory don't include the most recent messages, then we
// won't add new messages to our message list.
const haveLatest = newest && newest.id === lastMessageId;
if (!haveLatest) {
return state;
}
}
const newIds = messages.map(message => message.id);
const newChanges = intersection(newIds, existingConversation.messageIds);
const heightChangeMessageIds = uniq([
...newChanges,
...existingConversation.heightChangeMessageIds,
]);
const lookup = fromPairs(
existingConversation.messageIds.map(id => [id, messagesLookup[id]])
);
messages.forEach(message => {
lookup[message.id] = message;
});
const sorted = orderBy(values(lookup), ['received_at'], ['ASC']);
const messageIds = sorted.map(message => message.id);
const first = sorted[0];
const last = sorted.length > 0 ? sorted[sorted.length - 1] : null;
if (first && oldest && first.received_at < oldest.received_at) {
oldest = pick(first, ['id', 'received_at']);
}
if (last && newest && last.received_at > newest.received_at) {
newest = pick(last, ['id', 'received_at']);
}
const newMessageIds = difference(newIds, existingConversation.messageIds);
const { isNearBottom } = existingConversation;
if ((!isNearBottom || !isFocused) && !oldestUnread) {
const oldestId = newMessageIds.find(messageId => {
const message = lookup[messageId];
return Boolean(message.unread);
});
if (oldestId) {
oldestUnread = pick(lookup[oldestId], [
'id',
'received_at',
]) as MessagePointerType;
}
}
if (oldestUnread) {
const newUnread: number = newMessageIds.reduce((sum, messageId) => {
const message = lookup[messageId];
return sum + (message && message.unread ? 1 : 0);
}, 0);
totalUnread = (totalUnread || 0) + newUnread;
}
return {
...state,
messagesLookup: {
...messagesLookup,
...lookup,
},
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
isLoadingMessages: false,
messageIds,
heightChangeMessageIds,
scrollToMessageId: undefined,
metrics: {
...existingConversation.metrics,
newest,
oldest,
totalUnread,
oldestUnread,
},
},
},
};
}
if (action.type === 'CLEAR_SELECTED_MESSAGE') {
return {
...state,
selectedMessage: undefined,
};
}
if (action.type === 'CLEAR_CHANGED_MESSAGES') {
const { payload } = action;
const { conversationId } = payload;
const existingConversation = state.messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...existingConversation,
heightChangeMessageIds: [],
},
},
};
}
if (action.type === 'CLEAR_UNREAD_METRICS') {
const { payload } = action;
const { conversationId } = payload;
const existingConversation = state.messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...existingConversation,
metrics: {
...existingConversation.metrics,
oldestUnread: undefined,
totalUnread: 0,
},
},
},
};
} }
if (action.type === 'SELECTED_CONVERSATION_CHANGED') { if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
const { payload } = action; const { payload } = action;

View file

@ -1,18 +1,14 @@
import { AnyAction } from 'redux';
import { omit, reject } from 'lodash'; import { omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber'; import { normalize } from '../../types/PhoneNumber';
import { trigger } from '../../shims/events'; import { trigger } from '../../shims/events';
// import { getMessageModel } from '../../shims/Whisper'; import { cleanSearchTerm } from '../../util/cleanSearchTerm';
// import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { searchConversations, searchMessages } from '../../../js/modules/data';
import {
searchConversations /*, searchMessages */,
} from '../../../js/modules/data';
import { makeLookup } from '../../util/makeLookup'; import { makeLookup } from '../../util/makeLookup';
import { import {
ConversationType, ConversationType,
MessageExpiredActionType, MessageDeletedActionType,
MessageSearchResultType, MessageSearchResultType,
RemoveAllConversationsActionType, RemoveAllConversationsActionType,
SelectedConversationChangedActionType, SelectedConversationChangedActionType,
@ -64,11 +60,10 @@ type ClearSearchActionType = {
}; };
export type SEARCH_TYPES = export type SEARCH_TYPES =
| AnyAction
| SearchResultsFulfilledActionType | SearchResultsFulfilledActionType
| UpdateSearchTermActionType | UpdateSearchTermActionType
| ClearSearchActionType | ClearSearchActionType
| MessageExpiredActionType | MessageDeletedActionType
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| SelectedConversationChangedActionType; | SelectedConversationChangedActionType;
@ -101,9 +96,9 @@ async function doSearch(
): Promise<SearchResultsPayloadType> { ): Promise<SearchResultsPayloadType> {
const { regionCode, ourNumber, noteToSelf } = options; const { regionCode, ourNumber, noteToSelf } = options;
const [discussions /*, messages */] = await Promise.all([ const [discussions, messages] = await Promise.all([
queryConversationsAndContacts(query, { ourNumber, noteToSelf }), queryConversationsAndContacts(query, { ourNumber, noteToSelf }),
// queryMessages(query), queryMessages(query),
]); ]);
const { conversations, contacts } = discussions; const { conversations, contacts } = discussions;
@ -112,7 +107,7 @@ async function doSearch(
normalizedPhoneNumber: normalize(query, { regionCode }), normalizedPhoneNumber: normalize(query, { regionCode }),
conversations, conversations,
contacts, contacts,
messages: [], // getMessageProps(messages) || [], messages,
}; };
} }
function clearSearch(): ClearSearchActionType { function clearSearch(): ClearSearchActionType {
@ -146,29 +141,15 @@ function startNewConversation(
}; };
} }
// Helper functions for search async function queryMessages(query: string) {
try {
const normalized = cleanSearchTerm(query);
// const getMessageProps = (messages: Array<MessageSearchResultType>) => { return searchMessages(normalized);
// if (!messages || !messages.length) { } catch (e) {
// return []; return [];
// } }
}
// return messages.map(message => {
// const model = getMessageModel(message);
// return model.propsForSearchResult;
// });
// };
// async function queryMessages(query: string) {
// try {
// const normalized = cleanSearchTerm(query);
// return searchMessages(normalized);
// } catch (e) {
// return [];
// }
// }
async function queryConversationsAndContacts( async function queryConversationsAndContacts(
providedQuery: string, providedQuery: string,
@ -271,7 +252,7 @@ export function reducer(
}; };
} }
if (action.type === 'MESSAGE_EXPIRED') { if (action.type === 'MESSAGE_DELETED') {
const { messages, messageLookup } = state; const { messages, messageLookup } = state;
if (!messages.length) { if (!messages.length) {
return state; return state;

View file

@ -9,8 +9,8 @@ import { SmartTimeline } from '../smart/Timeline';
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredTimeline = SmartTimeline as any; const FilteredTimeline = SmartTimeline as any;
export const createTimeline = (store: Store) => ( export const createTimeline = (store: Store, props: Object) => (
<Provider store={store}> <Provider store={store}>
<FilteredTimeline /> <FilteredTimeline {...props} />
</Provider> </Provider>
); );

View file

@ -6,12 +6,16 @@ import { LocalizerType } from '../../types/Util';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { import {
ConversationLookupType, ConversationLookupType,
ConversationMessageType,
ConversationsStateType, ConversationsStateType,
ConversationType, ConversationType,
MessageLookupType, MessageLookupType,
MessagesByConversationType, MessagesByConversationType,
MessageType, MessageType,
} from '../ducks/conversations'; } from '../ducks/conversations';
import { getBubbleProps } from '../../shims/Whisper';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem';
import { getIntl, getRegionCode, getUserNumber } from './user'; import { getIntl, getRegionCode, getUserNumber } from './user';
@ -32,6 +36,24 @@ export const getSelectedConversation = createSelector(
} }
); );
type SelectedMessageType = {
id: string;
counter: number;
};
export const getSelectedMessage = createSelector(
getConversations,
(state: ConversationsStateType): SelectedMessageType | undefined => {
if (!state.selectedMessage) {
return;
}
return {
id: state.selectedMessage,
counter: state.selectedMessageCounter,
};
}
);
export const getShowArchived = createSelector( export const getShowArchived = createSelector(
getConversations, getConversations,
(state: ConversationsStateType): boolean => { (state: ConversationsStateType): boolean => {
@ -160,9 +182,12 @@ export const getMe = createSelector(
); );
// This is where we will put Conversation selector logic, replicating what // This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps() // is currently in models/conversation.getProps()
// Blockers: // What needs to happen to pull that selector logic here?
// 1) contactTypingTimers - that UI-only state needs to be moved to redux // 1) contactTypingTimers - that UI-only state needs to be moved to redux
// 2) all of the message selectors need to be reselect-based; today those
// Backbone-based prop-generation functions expect to get Conversation information
// directly via ConversationController
export function _conversationSelector( export function _conversationSelector(
conversation: ConversationType conversation: ConversationType
// regionCode: string, // regionCode: string,
@ -180,6 +205,8 @@ export const getCachedSelectorForConversation = createSelector(
getRegionCode, getRegionCode,
getUserNumber, getUserNumber,
(): CachedConversationSelectorType => { (): CachedConversationSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_conversationSelector, { max: 100 }); return memoizee(_conversationSelector, { max: 100 });
} }
); );
@ -203,49 +230,200 @@ export const getConversationSelector = createSelector(
} }
); );
// For now we pass through, as selector logic is still happening in the Backbone Model. // For now we use a shim, as selector logic is still happening in the Backbone Model.
// Blockers: // What needs to happen to pull that selector logic here?
// 1) it's a lot of code to pull over - ~500 lines // 1) translate ~500 lines of selector logic into TypeScript
// 2) a couple places still rely on all that code - will need to move these to Roots: // 2) other places still rely on that prop-gen code - need to put these under Roots:
// - quote compose // - quote compose
// - message details // - message details
export function _messageSelector( export function _messageSelector(
message: MessageType message: MessageType,
// ourNumber: string, // @ts-ignore
// regionCode: string, ourNumber: string,
// conversation?: ConversationType, // @ts-ignore
// sender?: ConversationType, regionCode: string,
// quoted?: ConversationType // @ts-ignore
): MessageType { conversation?: ConversationType,
return message; // @ts-ignore
author?: ConversationType,
// @ts-ignore
quoted?: ConversationType,
selectedMessageId?: string,
selectedMessageCounter?: number
): TimelineItemType {
// Note: We don't use all of those parameters here, but the shim we call does.
// We want to call this function again if any of those parameters change.
const props = getBubbleProps(message);
if (selectedMessageId === message.id) {
return {
...props,
data: {
...props.data,
isSelected: true,
isSelectedCounter: selectedMessageCounter,
},
};
}
return props;
} }
// A little optimization to reset our selector cache whenever high-level application data // A little optimization to reset our selector cache whenever high-level application data
// changes: regionCode and userNumber. // changes: regionCode and userNumber.
type CachedMessageSelectorType = (message: MessageType) => MessageType; type CachedMessageSelectorType = (
message: MessageType,
ourNumber: string,
regionCode: string,
conversation?: ConversationType,
author?: ConversationType,
quoted?: ConversationType,
selectedMessageId?: string,
selectedMessageCounter?: number
) => TimelineItemType;
export const getCachedSelectorForMessage = createSelector( export const getCachedSelectorForMessage = createSelector(
getRegionCode, getRegionCode,
getUserNumber, getUserNumber,
(): CachedMessageSelectorType => { (): CachedMessageSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_messageSelector, { max: 500 }); return memoizee(_messageSelector, { max: 500 });
} }
); );
type GetMessageByIdType = (id: string) => MessageType | undefined; type GetMessageByIdType = (id: string) => TimelineItemType | undefined;
export const getMessageSelector = createSelector( export const getMessageSelector = createSelector(
getCachedSelectorForMessage, getCachedSelectorForMessage,
getMessages, getMessages,
getSelectedMessage,
getConversationSelector,
getRegionCode,
getUserNumber,
( (
selector: CachedMessageSelectorType, messageSelector: CachedMessageSelectorType,
lookup: MessageLookupType messageLookup: MessageLookupType,
selectedMessage: SelectedMessageType | undefined,
conversationSelector: GetConversationByIdType,
regionCode: string,
ourNumber: string
): GetMessageByIdType => { ): GetMessageByIdType => {
return (id: string) => { return (id: string) => {
const message = lookup[id]; const message = messageLookup[id];
if (!message) { if (!message) {
return; return;
} }
return selector(message); const { conversationId, source, type, quote } = message;
const conversation = conversationSelector(conversationId);
let author: ConversationType | undefined;
let quoted: ConversationType | undefined;
if (type === 'incoming') {
author = conversationSelector(source);
} else if (type === 'outgoing') {
author = conversationSelector(ourNumber);
}
if (quote) {
quoted = conversationSelector(quote.author);
}
return messageSelector(
message,
ourNumber,
regionCode,
conversation,
author,
quoted,
selectedMessage ? selectedMessage.id : undefined,
selectedMessage ? selectedMessage.counter : undefined
);
};
}
);
export function _conversationMessagesSelector(
conversation: ConversationMessageType
): TimelinePropsType {
const {
heightChangeMessageIds,
isLoadingMessages,
loadCountdownStart,
messageIds,
metrics,
resetCounter,
scrollToMessageId,
scrollToMessageCounter,
} = conversation;
const firstId = messageIds[0];
const lastId =
messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1];
const { oldestUnread } = metrics;
const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id;
const haveOldest =
!metrics.oldest || !firstId || firstId === metrics.oldest.id;
const items = messageIds;
const messageHeightChanges = Boolean(
heightChangeMessageIds && heightChangeMessageIds.length
);
const oldestUnreadIndex = oldestUnread
? messageIds.findIndex(id => id === oldestUnread.id)
: undefined;
const scrollToIndex = scrollToMessageId
? messageIds.findIndex(id => id === scrollToMessageId)
: undefined;
const { totalUnread } = metrics;
return {
haveNewest,
haveOldest,
isLoadingMessages,
loadCountdownStart,
items,
messageHeightChanges,
oldestUnreadIndex:
oldestUnreadIndex && oldestUnreadIndex >= 0
? oldestUnreadIndex
: undefined,
resetCounter,
scrollToIndex:
scrollToIndex && scrollToIndex >= 0 ? scrollToIndex : undefined,
scrollToIndexCounter: scrollToMessageCounter,
totalUnread,
};
}
type CachedConversationMessagesSelectorType = (
conversation: ConversationMessageType
) => TimelinePropsType;
export const getCachedSelectorForConversationMessages = createSelector(
getRegionCode,
getUserNumber,
(): CachedConversationMessagesSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_conversationMessagesSelector, { max: 50 });
}
);
export const getConversationMessagesSelector = createSelector(
getCachedSelectorForConversationMessages,
getMessagesByConversation,
(
conversationMessagesSelector: CachedConversationMessagesSelectorType,
messagesByConversation: MessagesByConversationType
) => {
return (id: string): TimelinePropsType | undefined => {
const conversation = messagesByConversation[id];
if (!conversation) {
return;
}
return conversationMessagesSelector(conversation);
}; };
} }
); );

View file

@ -1,5 +1,6 @@
import { compact } from 'lodash'; import { compact } from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { getSearchResultsProps } from '../../shims/Whisper';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
@ -79,14 +80,16 @@ export const getSearchResults = createSelector(
), ),
hideMessagesHeader: false, hideMessagesHeader: false,
messages: state.messages.map(message => { messages: state.messages.map(message => {
const props = getSearchResultsProps(message);
if (message.id === selectedMessage) { if (message.id === selectedMessage) {
return { return {
...message, ...props,
isSelected: true, isSelected: true,
}; };
} }
return message; return props;
}), }),
regionCode: regionCode, regionCode: regionCode,
searchTerm: state.query, searchTerm: state.query,

View file

@ -0,0 +1,32 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { LastSeenIndicator } from '../../components/conversation/LastSeenIndicator';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getConversationMessagesSelector } from '../selectors/conversations';
type ExternalProps = {
id: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversation = getConversationMessagesSelector(state)(id);
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
}
const { totalUnread } = conversation;
return {
count: totalUnread,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartLastSeenIndicator = smart(LastSeenIndicator);

View file

@ -14,6 +14,10 @@ import { SmartMainHeader } from './MainHeader';
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartMainHeader = SmartMainHeader as any; const FilteredSmartMainHeader = SmartMainHeader as any;
function renderMainHeader(): JSX.Element {
return <FilteredSmartMainHeader />;
}
const mapStateToProps = (state: StateType) => { const mapStateToProps = (state: StateType) => {
const showSearch = isSearching(state); const showSearch = isSearching(state);
@ -25,7 +29,7 @@ const mapStateToProps = (state: StateType) => {
searchResults, searchResults,
showArchived: getShowArchived(state), showArchived: getShowArchived(state),
i18n: getIntl(state), i18n: getIntl(state),
renderMainHeader: () => <FilteredSmartMainHeader />, renderMainHeader,
}; };
}; };

View file

@ -1,3 +1,4 @@
import { pick } from 'lodash';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
@ -5,32 +6,59 @@ import { Timeline } from '../../components/conversation/Timeline';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations'; import {
getConversationMessagesSelector,
getConversationSelector,
} from '../selectors/conversations';
import { SmartTimelineItem } from './TimelineItem'; import { SmartTimelineItem } from './TimelineItem';
import { SmartTypingBubble } from './TypingBubble';
import { SmartLastSeenIndicator } from './LastSeenIndicator';
import { SmartTimelineLoadingRow } from './TimelineLoadingRow';
// Workaround: A react component's required properties are filtering up through connect() // Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartTimelineItem = SmartTimelineItem as any; const FilteredSmartTimelineItem = SmartTimelineItem as any;
const FilteredSmartTypingBubble = SmartTypingBubble as any;
const FilteredSmartLastSeenIndicator = SmartLastSeenIndicator as any;
const FilteredSmartTimelineLoadingRow = SmartTimelineLoadingRow as any;
type ExternalProps = { type ExternalProps = {
id: string; id: string;
// Note: most action creators are not wired into redux; for now they
// are provided by ConversationView in setupTimeline().
}; };
const mapStateToProps = (state: StateType, props: ExternalProps) => { function renderItem(messageId: string, actionProps: Object): JSX.Element {
const { id } = props; return <FilteredSmartTimelineItem {...actionProps} id={messageId} />;
}
function renderLastSeenIndicator(id: string): JSX.Element {
return <FilteredSmartLastSeenIndicator id={id} />;
}
function renderLoadingRow(id: string): JSX.Element {
return <FilteredSmartTimelineLoadingRow id={id} />;
}
function renderTypingBubble(id: string): JSX.Element {
return <FilteredSmartTypingBubble id={id} />;
}
const conversationSelector = getConversationSelector(state); const mapStateToProps = (state: StateType, props: ExternalProps) => {
const conversation = conversationSelector(id); const { id, ...actions } = props;
const items: Array<string> = [];
const conversation = getConversationSelector(state)(id);
const conversationMessages = getConversationMessagesSelector(state)(id);
return { return {
...conversation, id,
items, ...pick(conversation, ['unreadCount', 'typingContact']),
...conversationMessages,
i18n: getIntl(state), i18n: getIntl(state),
renderTimelineItem: (messageId: string) => { renderItem,
return <FilteredSmartTimelineItem id={messageId} />; renderLastSeenIndicator,
}, renderLoadingRow,
renderTypingBubble,
...actions,
}; };
}; };

View file

@ -14,9 +14,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props; const { id } = props;
const messageSelector = getMessageSelector(state); const messageSelector = getMessageSelector(state);
const item = messageSelector(id);
return { return {
...messageSelector(id), item,
i18n: getIntl(state), i18n: getIntl(state),
}; };
}; };

View file

@ -0,0 +1,50 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { isNumber } from 'lodash';
import {
STATE_ENUM,
TimelineLoadingRow,
} from '../../components/conversation/TimelineLoadingRow';
import { LOAD_COUNTDOWN } from '../../components/conversation/Timeline';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getConversationMessagesSelector } from '../selectors/conversations';
type ExternalProps = {
id: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversation = getConversationMessagesSelector(state)(id);
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
}
const { isLoadingMessages, loadCountdownStart } = conversation;
const loadingState: STATE_ENUM = isLoadingMessages
? 'loading'
: isNumber(loadCountdownStart)
? 'countdown'
: 'idle';
const duration = loadingState === 'countdown' ? LOAD_COUNTDOWN : undefined;
const expiresAt =
loadingState === 'countdown' && loadCountdownStart
? loadCountdownStart + LOAD_COUNTDOWN
: undefined;
return {
state: loadingState,
duration,
expiresAt,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartTimelineLoadingRow = smart(TimelineLoadingRow);

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { TypingBubble } from '../../components/conversation/TypingBubble';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
type ExternalProps = {
id: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversation = getConversationSelector(state)(id);
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
}
return {
...conversation.typingContact,
conversationType: conversation.type,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartTypingBubble = smart(TypingBubble);

View file

@ -7,7 +7,6 @@ interface Props {
*/ */
ios: boolean; ios: boolean;
theme: 'light-theme' | 'dark-theme'; theme: 'light-theme' | 'dark-theme';
type: 'private' | 'group';
} }
/** /**
@ -16,16 +15,17 @@ interface Props {
*/ */
export class ConversationContext extends React.Component<Props> { export class ConversationContext extends React.Component<Props> {
public render() { public render() {
const { ios, theme, type } = this.props; const { ios, theme } = this.props;
return ( return (
<div <div
className={classNames(theme || 'light-theme', ios ? 'ios-theme' : null)} className={classNames(theme || 'light-theme', ios ? 'ios-theme' : null)}
style={{
backgroundColor: theme === 'dark-theme' ? 'black' : undefined,
}}
> >
<div className={classNames('conversation', type || 'private')}> <div className="timeline-placeholder">
<div className="discussion-container" style={{ padding: '0.5em' }}> <div className="timeline-wrapper">{this.props.children}</div>
<ul className="message-list">{this.props.children}</ul>
</div>
</div> </div>
</div> </div>
); );

View file

@ -25,7 +25,11 @@ describe('state/selectors/conversations', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
isTyping: false, typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
}, },
id2: { id2: {
id: 'id2', id: 'id2',
@ -40,7 +44,11 @@ describe('state/selectors/conversations', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
isTyping: false, typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
}, },
id3: { id3: {
id: 'id3', id: 'id3',
@ -55,7 +63,11 @@ describe('state/selectors/conversations', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
isTyping: false, typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
}, },
id4: { id4: {
id: 'id4', id: 'id4',
@ -70,7 +82,11 @@ describe('state/selectors/conversations', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
isTyping: false, typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
}, },
id5: { id5: {
id: 'id5', id: 'id5',
@ -85,7 +101,11 @@ describe('state/selectors/conversations', () => {
lastUpdated: Date.now(), lastUpdated: Date.now(),
unreadCount: 1, unreadCount: 1,
isSelected: false, isSelected: false,
isTyping: false, typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
}, },
}; };
const comparator = _getConversationComparator(i18n, regionCode); const comparator = _getConversationComparator(i18n, regionCode);

View file

@ -164,17 +164,17 @@
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/conversation_controller.js", "path": "js/conversation_controller.js",
"line": " async load() {", "line": " async load() {",
"lineNumber": 178, "lineNumber": 169,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2019-07-31T00:19:18.696Z"
}, },
{ {
"rule": "jQuery-load(", "rule": "jQuery-load(",
"path": "js/conversation_controller.js", "path": "js/conversation_controller.js",
"line": " this._initialPromise = load();", "line": " this._initialPromise = load();",
"lineNumber": 213, "lineNumber": 204,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z" "updated": "2019-07-31T00:19:18.696Z"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
@ -363,8 +363,8 @@
"line": " this.$el.append(this.contactView.el);", "line": " this.$el.append(this.contactView.el);",
"lineNumber": 46, "lineNumber": 46,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-10-02T21:18:39.026Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Operating on previously-existing DOM elements" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
@ -474,148 +474,139 @@
"updated": "2018-09-15T00:38:04.183Z" "updated": "2018-09-15T00:38:04.183Z"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " let $el = this.$(`#${id}`);", "line": " view.$el.appendTo(this.el);",
"lineNumber": 34, "lineNumber": 32,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js",
"line": " $el.prependTo(this.el);",
"lineNumber": 43,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.message').text(message);", "line": " this.$('.message').text(message);",
"lineNumber": 61, "lineNumber": 58,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " el: this.$('.conversation-stack'),", "line": " el: this.$('.conversation-stack'),",
"lineNumber": 78, "lineNumber": 75,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-prependTo(", "rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.appLoadingScreen.$el.prependTo(this.el);", "line": " this.appLoadingScreen.$el.prependTo(this.el);",
"lineNumber": 85, "lineNumber": 82,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " .append(this.networkStatusView.render().el);", "line": " .append(this.networkStatusView.render().el);",
"lineNumber": 100, "lineNumber": 97,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-prependTo(", "rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " banner.$el.prependTo(this.$el);", "line": " banner.$el.prependTo(this.$el);",
"lineNumber": 104, "lineNumber": 101,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 110, "lineNumber": 107,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-05-10T00:25:51.515Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 130, "lineNumber": 126,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 130, "lineNumber": 126,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.placeholder').length) {", "line": " if (e && this.$(e.target).closest('.placeholder').length) {",
"lineNumber": 171, "lineNumber": 167,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('#header, .gutter').addClass('inactive');", "line": " this.$('#header, .gutter').addClass('inactive');",
"lineNumber": 175, "lineNumber": 171,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.conversation-stack').addClass('inactive');", "line": " this.$('.conversation-stack').addClass('inactive');",
"lineNumber": 179, "lineNumber": 175,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .menu').trigger('close');", "line": " this.$('.conversation:first .menu').trigger('close');",
"lineNumber": 181, "lineNumber": 177,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 201, "lineNumber": 197,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/inbox_view.js", "path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .recorder').trigger('close');", "line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 204, "lineNumber": 200,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
@ -722,8 +713,8 @@
"line": " new QRCode(this.$('.qr')[0]).makeCode(", "line": " new QRCode(this.$('.qr')[0]).makeCode(",
"lineNumber": 39, "lineNumber": 39,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
@ -731,7 +722,7 @@
"line": " dcodeIO.ByteBuffer.wrap(this.ourKey).toString('base64')", "line": " dcodeIO.ByteBuffer.wrap(this.ourKey).toString('base64')",
"lineNumber": 40, "lineNumber": 40,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2019-07-31T00:19:18.696Z"
}, },
{ {
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
@ -739,8 +730,8 @@
"line": " dialog.$el.insertBefore(this.el);", "line": " dialog.$el.insertBefore(this.el);",
"lineNumber": 75, "lineNumber": 75,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Known DOM elements"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
@ -748,8 +739,8 @@
"line": " this.$('button.verify').attr('disabled', true);", "line": " this.$('button.verify').attr('disabled', true);",
"lineNumber": 79, "lineNumber": 79,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
@ -757,8 +748,8 @@
"line": " this.$('button.verify').removeAttr('disabled');", "line": " this.$('button.verify').removeAttr('disabled');",
"lineNumber": 110, "lineNumber": 110,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Hardcoded selector"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
@ -778,105 +769,6 @@
"updated": "2018-09-15T00:38:04.183Z", "updated": "2018-09-15T00:38:04.183Z",
"reasonDetail": "Hard-coded value" "reasonDetail": "Hard-coded value"
}, },
{
"rule": "jQuery-$(",
"path": "js/views/message_list_view.js",
"line": " template: $('#message-list').html(),",
"lineNumber": 13,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "Parameter is a hard-coded string"
},
{
"rule": "jQuery-html(",
"path": "js/views/message_list_view.js",
"line": " template: $('#message-list').html(),",
"lineNumber": 13,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "This is run at JS load time, which means we control the contents of the target element"
},
{
"rule": "jQuery-$(",
"path": "js/views/message_list_view.js",
"line": " this.$messages = this.$('.messages');",
"lineNumber": 30,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "Parameter is a hard-coded string"
},
{
"rule": "jQuery-append(",
"path": "js/views/message_list_view.js",
"line": " this.$messages.append(view.el);",
"lineNumber": 111,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element"
},
{
"rule": "jQuery-prepend(",
"path": "js/views/message_list_view.js",
"line": " this.$messages.prepend(view.el);",
"lineNumber": 114,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element"
},
{
"rule": "jQuery-$(",
"path": "js/views/message_list_view.js",
"line": " const next = this.$(`#${this.collection.at(index + 1).id}`);",
"lineNumber": 117,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "Message ids are GUIDs, and therefore the resultant string for $() is an id"
},
{
"rule": "jQuery-insertBefore(",
"path": "js/views/message_list_view.js",
"line": " view.$el.insertBefore(next);",
"lineNumber": 120,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "next is a known DOM element"
},
{
"rule": "jQuery-insertAfter(",
"path": "js/views/message_list_view.js",
"line": " view.$el.insertAfter(prev);",
"lineNumber": 122,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "prev is a known DOM element"
},
{
"rule": "jQuery-insertBefore(",
"path": "js/views/message_list_view.js",
"line": " view.$el.insertBefore(elements[i]);",
"lineNumber": 131,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "elements[i] is a known DOM element"
},
{
"rule": "jQuery-append(",
"path": "js/views/message_list_view.js",
"line": " this.$messages.append(view.el);",
"lineNumber": 136,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element"
},
{
"rule": "jQuery-append(",
"path": "js/views/message_view.js",
"line": " this.$el.append(this.childView.el);",
"lineNumber": 144,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/phone-input-view.js", "path": "js/views/phone-input-view.js",
@ -1453,6 +1345,45 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-15T00:38:04.183Z" "updated": "2018-09-15T00:38:04.183Z"
}, },
{
"rule": "jQuery-$(",
"path": "node_modules/@yarnpkg/lockfile/index.js",
"lineNumber": 6546,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/@yarnpkg/lockfile/index.js",
"line": "function load() {",
"lineNumber": 8470,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/@yarnpkg/lockfile/index.js",
"line": "exports.enable(load());",
"lineNumber": 8488,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/@yarnpkg/lockfile/index.js",
"line": "function load() {",
"lineNumber": 8689,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/@yarnpkg/lockfile/index.js",
"line": "exports.enable(load());",
"lineNumber": 8713,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{ {
"rule": "jQuery-after(", "rule": "jQuery-after(",
"path": "node_modules/archiver-utils/node_modules/lodash/after.js", "path": "node_modules/archiver-utils/node_modules/lodash/after.js",
@ -3590,20 +3521,36 @@
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-load(",
"path": "node_modules/extglob/index.js", "path": "node_modules/extglob/node_modules/debug/src/browser.js",
"line": " o[id] = wrap(inner, prefix, opts.escape);", "line": "function load() {",
"lineNumber": 85, "lineNumber": 150,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2019-07-31T00:19:18.696Z"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-load(",
"path": "node_modules/extglob/index.js", "path": "node_modules/extglob/node_modules/debug/src/browser.js",
"line": "function wrap(inner, prefix, esc) {", "line": "exports.enable(load());",
"lineNumber": 119, "lineNumber": 168,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/extglob/node_modules/debug/src/node.js",
"line": "function load() {",
"lineNumber": 156,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/extglob/node_modules/debug/src/node.js",
"line": "exports.enable(load());",
"lineNumber": 248,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
}, },
{ {
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
@ -5219,46 +5166,6 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:06:35.446Z" "updated": "2018-09-19T18:06:35.446Z"
}, },
{
"rule": "jQuery-before(",
"path": "node_modules/micromatch/node_modules/braces/index.js",
"line": " str = tokens.before(str, es6Regex());",
"lineNumber": 92,
"reasonCategory": "falseMatch",
"updated": "2018-09-15T00:38:04.183Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/micromatch/node_modules/braces/index.js",
"line": " return braces(str.replace(outter, wrap(segs, '|')), opts);",
"lineNumber": 121,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/micromatch/node_modules/braces/index.js",
"line": " segs[0] = wrap(segs[0], '\\\\');",
"lineNumber": 126,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-after(",
"path": "node_modules/micromatch/node_modules/braces/index.js",
"line": " arr.push(es6 ? tokens.after(val) : val);",
"lineNumber": 150,
"reasonCategory": "falseMatch",
"updated": "2018-09-15T00:38:04.183Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/micromatch/node_modules/braces/index.js",
"line": "function wrap(val, ch) {",
"lineNumber": 216,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{ {
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "node_modules/min-document/serialize.js", "path": "node_modules/min-document/serialize.js",
@ -7161,6 +7068,62 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
{
"rule": "jQuery-before(",
"path": "node_modules/test-exclude/node_modules/braces/index.js",
"line": " str = tokens.before(str, es6Regex());",
"lineNumber": 92,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/test-exclude/node_modules/braces/index.js",
"line": " return braces(str.replace(outter, wrap(segs, '|')), opts);",
"lineNumber": 121,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/test-exclude/node_modules/braces/index.js",
"line": " segs[0] = wrap(segs[0], '\\\\');",
"lineNumber": 126,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-after(",
"path": "node_modules/test-exclude/node_modules/braces/index.js",
"line": " arr.push(es6 ? tokens.after(val) : val);",
"lineNumber": 150,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/test-exclude/node_modules/braces/index.js",
"line": "function wrap(val, ch) {",
"lineNumber": 216,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/test-exclude/node_modules/extglob/index.js",
"line": " o[id] = wrap(inner, prefix, opts.escape);",
"lineNumber": 85,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/test-exclude/node_modules/extglob/index.js",
"line": "function wrap(inner, prefix, esc) {",
"lineNumber": 119,
"reasonCategory": "falseMatch",
"updated": "2019-07-31T00:19:18.696Z"
},
{ {
"rule": "eval", "rule": "eval",
"path": "node_modules/thenify/index.js", "path": "node_modules/thenify/index.js",
@ -7849,8 +7812,8 @@
"line": " this.menuTriggerRef = react_1.default.createRef();", "line": " this.menuTriggerRef = react_1.default.createRef();",
"lineNumber": 14, "lineNumber": 14,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Used only to trigger menu display" "reasonDetail": "Used to reference popup menu"
}, },
{ {
"rule": "React-createRef", "rule": "React-createRef",
@ -7858,17 +7821,17 @@
"line": " this.menuTriggerRef = React.createRef();", "line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 59, "lineNumber": 59,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Used only to trigger menu display" "reasonDetail": "Used to reference popup menu"
}, },
{ {
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/Timeline.js", "path": "ts/components/conversation/Timeline.js",
"line": " this.listRef = react_1.default.createRef();", "line": " this.listRef = react_1.default.createRef();",
"lineNumber": 17, "lineNumber": 27,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-04-17T18:44:33.207Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Necessary to interact with child react-virtualized/List" "reasonDetail": "Timeline needs to interact with its child List directly"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",

View file

@ -93,7 +93,13 @@
"allow-pascal-case" "allow-pascal-case"
], ],
"function-name": [true, { "function-regex": "^_?[a-z][\\w\\d]+$" }], "function-name": [
true,
{
"function-regex": "^_?[a-z][\\w\\d]+$",
"static-method-regex": "^_?[a-z][\\w\\d]+$"
}
],
// Adding select dev dependencies here for now, may turn on all in the future // Adding select dev dependencies here for now, may turn on all in the future
"no-implicit-dependencies": [true, ["dashdash", "electron"]], "no-implicit-dependencies": [true, ["dashdash", "electron"]],

View file

@ -313,6 +313,11 @@
dependencies: dependencies:
common-tags "^1.7.2" common-tags "^1.7.2"
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
abbrev@1: abbrev@1:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f"
@ -2067,7 +2072,7 @@ cross-spawn@^4:
lru-cache "^4.0.1" lru-cache "^4.0.1"
which "^1.2.9" which "^1.2.9"
cross-spawn@^6.0.0: cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5" version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
@ -3526,6 +3531,14 @@ find-up@^3.0.0:
dependencies: dependencies:
locate-path "^3.0.0" locate-path "^3.0.0"
find-yarn-workspace-root@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-1.2.1.tgz#40eb8e6e7c2502ddfaa2577c176f221422f860db"
integrity sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q==
dependencies:
fs-extra "^4.0.3"
micromatch "^3.1.4"
findup-sync@~0.3.0: findup-sync@~0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.3.0.tgz#37930aa5d816b777c03445e1966cc6790a4c0b16" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.3.0.tgz#37930aa5d816b777c03445e1966cc6790a4c0b16"
@ -3684,6 +3697,15 @@ fs-extra@^2.0.0:
graceful-fs "^4.1.2" graceful-fs "^4.1.2"
jsonfile "^2.1.0" jsonfile "^2.1.0"
fs-extra@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94"
integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==
dependencies:
graceful-fs "^4.1.2"
jsonfile "^4.0.0"
universalify "^0.1.0"
fs-extra@^7.0.1: fs-extra@^7.0.1:
version "7.0.1" version "7.0.1"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
@ -3916,6 +3938,18 @@ glob@^7.0.3, glob@~7.0.0:
once "^1.3.0" once "^1.3.0"
path-is-absolute "^1.0.0" path-is-absolute "^1.0.0"
glob@^7.1.3:
version "7.1.4"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@~5.0.0: glob@~5.0.0:
version "5.0.15" version "5.0.15"
resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
@ -5402,6 +5436,13 @@ kind-of@^6.0.0, kind-of@^6.0.2:
version "6.0.2" version "6.0.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
klaw-sync@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c"
integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==
dependencies:
graceful-fs "^4.1.11"
klaw@^1.0.0: klaw@^1.0.0:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439"
@ -6935,6 +6976,25 @@ pascalcase@^0.1.1:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
patch-package@6.1.2:
version "6.1.2"
resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-6.1.2.tgz#9ed0b3defb5c34ecbef3f334ddfb13e01b3d3ff6"
integrity sha512-5GnzR8lEyeleeariG+hGabUnD2b1yL7AIGFjlLo95zMGRWhZCel58IpeKD46wwPb7i+uNhUI8unV56ogk8Bgqg==
dependencies:
"@yarnpkg/lockfile" "^1.1.0"
chalk "^2.4.2"
cross-spawn "^6.0.5"
find-yarn-workspace-root "^1.2.1"
fs-extra "^7.0.1"
is-ci "^2.0.0"
klaw-sync "^6.0.0"
minimist "^1.2.0"
rimraf "^2.6.3"
semver "^5.6.0"
slash "^2.0.0"
tmp "^0.0.33"
update-notifier "^2.5.0"
path-browserify@0.0.0: path-browserify@0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a"
@ -8514,6 +8574,13 @@ rimraf@2.6.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2:
dependencies: dependencies:
glob "^7.0.5" glob "^7.0.5"
rimraf@^2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
dependencies:
glob "^7.1.3"
rimraf@~2.4.0: rimraf@~2.4.0:
version "2.4.5" version "2.4.5"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da"
@ -8791,6 +8858,11 @@ slash@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
slash@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
slice-ansi@1.0.0: slice-ansi@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"