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":
"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": {
"message": "New messages below",
"description":

View File

@ -16,6 +16,7 @@ const {
isString,
last,
map,
pick,
} = require('lodash');
// To get long stack traces
@ -93,9 +94,11 @@ module.exports = {
getExpiredMessages,
getOutgoingWithoutExpiresAt,
getNextExpiringMessage,
getMessagesByConversation,
getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase,
getOlderMessagesByConversation,
getNewerMessagesByConversation,
getMessageMetricsForConversation,
getUnprocessedCount,
getAllUnprocessed,
@ -1840,7 +1843,7 @@ async function getUnreadByConversation(conversationId) {
return map(rows, row => jsonToObject(row.json));
}
async function getMessagesByConversation(
async function getOlderMessagesByConversation(
conversationId,
{ 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));
}
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) {
const rows = await db.all(

View File

@ -71,19 +71,6 @@
<div class='lightbox-container'></div>
</script>
<script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
<button class='text module-scroll-down__button {{ buttonClass }}' alt='{{ moreBelow }}'>
<div class='module-scroll-down__icon'></div>
</button>
</script>
<script type='text/x-tmpl-mustache' id='last-seen-indicator-view'>
<div class='module-last-seen-indicator__bar'/>
<div class='module-last-seen-indicator__text'>
{{ unreadMessages }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='expired_alert'>
<a target='_blank' href='https://signal.org/download/'>
<button class='upgrade'>{{ upgrade }}</button>
@ -106,12 +93,7 @@
<script type='text/x-tmpl-mustache' id='conversation'>
<div class='conversation-header'></div>
<div class='main panel'>
<div class='discussion-container'>
<div class='bar-container hide'>
<div class='bar active progress-bar-striped progress-bar'></div>
</div>
</div>
<div class='timeline-placeholder'></div>
<div class='bottom-bar' id='footer'>
<div class='compose'>
<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/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/file_input_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/message_view.js'></script>
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script>

View File

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

View File

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

View File

@ -27,13 +27,7 @@
};
const { Util } = window.Signal;
const {
Conversation,
Contact,
Errors,
Message,
PhoneNumber,
} = window.Signal.Types;
const { Conversation, Contact, Message, PhoneNumber } = window.Signal.Types;
const {
deleteAttachmentData,
getAbsoluteAttachmentPath,
@ -277,6 +271,7 @@
this.messageCollection.remove(id);
existing.trigger('expired');
existing.cleanup();
};
// If a fetch is in progress, then we need to wait until that's complete to
@ -288,18 +283,33 @@
},
async onNewMessage(message) {
await this.updateLastMessage();
// Clear typing indicator for a given contact if we receive a message from them
const identifier = message.get
? `${message.get('source')}.${message.get('sourceDevice')}`
: `${message.source}.${message.sourceDevice}`;
this.clearContactTypingTimer(identifier);
await this.updateLastMessage();
},
addSingleMessage(message) {
const { id } = message;
const existing = this.messageCollection.get(id);
const model = this.messageCollection.add(message, { merge: true });
model.setToExpire();
if (!existing) {
const { messagesAdded } = window.reduxActions.conversations;
const isNewMessage = true;
messagesAdded(
this.id,
[model.getReduxData()],
isNewMessage,
document.hasFocus()
);
}
return model;
},
@ -310,7 +320,12 @@
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
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 = {
id: this.id,
@ -321,7 +336,7 @@
color,
type: this.isPrivate() ? 'direct' : 'group',
isMe: this.isMe(),
isTyping: typingKeys.length > 0,
typingContact: typingContact ? typingContact.format() : null,
lastUpdated: this.get('timestamp'),
name: this.getName(),
profileName: this.getProfileName(),
@ -894,6 +909,9 @@
sendMessage(body, attachments, quote, preview, sticker) {
this.clearTypingTimers();
const { clearUnreadMetrics } = window.reduxActions.conversations;
clearUnreadMetrics(this.id);
const destination = this.id;
const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients();
@ -1202,7 +1220,7 @@
return;
}
const messages = await window.Signal.Data.getMessagesByConversation(
const messages = await window.Signal.Data.getOlderMessagesByConversation(
this.id,
{ limit: 1, MessageCollection: Whisper.MessageCollection }
);
@ -1310,7 +1328,7 @@
model.set({ id });
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 (receivedAt) {
@ -1373,7 +1391,7 @@
async endSession() {
if (this.isPrivate()) {
const now = Date.now();
const message = this.messageCollection.add({
const model = new Whisper.Message({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
@ -1383,10 +1401,13 @@
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.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
@ -1407,7 +1428,7 @@
groupUpdate = this.pick(['name', 'avatar', 'members']);
}
const now = Date.now();
const message = this.messageCollection.add({
const model = new Whisper.Message({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
@ -1415,10 +1436,14 @@
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.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
@ -1443,7 +1468,7 @@
Conversation: Whisper.Conversation,
});
const message = this.messageCollection.add({
const model = new Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
@ -1451,10 +1476,13 @@
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.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
@ -1830,57 +1858,6 @@
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) {
return _.contains(this.get('members'), number);
},
@ -1908,10 +1885,6 @@
},
async destroyMessages() {
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
MessageCollection: Whisper.MessageCollection,
});
this.messageCollection.reset([]);
this.set({
@ -1922,6 +1895,10 @@
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
MessageCollection: Whisper.MessageCollection,
});
},
getName() {
@ -2102,10 +2079,6 @@
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) {
this.contactTypingTimers[identifier] = this.contactTypingTimers[
identifier
@ -2121,14 +2094,12 @@
);
if (!record) {
// User was not previously typing before. State change!
this.trigger('typing-update');
this.trigger('change', this);
}
} else {
delete this.contactTypingTimers[identifier];
if (record) {
// User was previously typing, and is no longer. State change!
this.trigger('typing-update');
this.trigger('change', this);
}
}
@ -2143,7 +2114,6 @@
delete this.contactTypingTimers[identifier];
// User was previously typing, but timed out or we received message. State change!
this.trigger('typing-update');
this.trigger('change', this);
}
},
@ -2155,17 +2125,6 @@
comparator(m) {
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(' ');

View File

@ -100,69 +100,67 @@
this.on('expired', this.onExpired);
this.setToExpire();
this.on('change', this.generateProps);
this.on('change', this.notifyRedux);
},
const applicableConversationChanges =
'change:color change:name change:number change:profileName change:profileAvatar';
notifyRedux() {
const { messageChanged } = window.reduxActions.conversations;
const conversation = this.getConversation();
const fromContact = this.getIncomingContact();
this.listenTo(
conversation,
applicableConversationChanges,
this.generateProps
);
if (fromContact) {
this.listenTo(
fromContact,
applicableConversationChanges,
this.generateProps
);
if (messageChanged) {
const conversationId = this.get('conversationId');
// Note: The clone is important for triggering a re-run of selectors
messageChanged(this.id, conversationId, _.clone(this.attributes));
}
},
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
generateProps() {
getPropsForBubble() {
if (this.isUnsupportedMessage()) {
this.props = {
return {
type: 'unsupportedMessage',
data: this.getPropsForUnsupportedMessage(),
};
} else if (this.isExpirationTimerUpdate()) {
this.props = {
return {
type: 'timerNotification',
data: this.getPropsForTimerNotification(),
};
} else if (this.isKeyChange()) {
this.props = {
return {
type: 'safetyNumberNotification',
data: this.getPropsForSafetyNumberNotification(),
};
} else if (this.isVerifiedChange()) {
this.props = {
return {
type: 'verificationNotification',
data: this.getPropsForVerificationNotification(),
};
} else if (this.isGroupUpdate()) {
this.props = {
return {
type: 'groupNotification',
data: this.getPropsForGroupNotification(),
};
} else if (this.isEndSession()) {
this.props = {
return {
type: 'resetSessionNotification',
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
@ -269,6 +267,21 @@
disableScroll: true,
// To ensure that group avatar doesn't show up
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,
contacts: sortedContacts,
@ -290,7 +303,7 @@
const flag =
textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
// eslint-disable-next-line no-bitwise
return !!(this.get('flags') & flag);
return Boolean(this.get('flags') & flag);
},
isKeyChange() {
return this.get('type') === 'keychange';
@ -353,12 +366,10 @@
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const phoneNumber = this.get('key_changed');
const showIdentity = id => this.trigger('show-identity', id);
return {
isGroup,
contact: this.findAndFormatContact(phoneNumber),
showIdentity,
};
},
getPropsForVerificationNotification() {
@ -498,28 +509,6 @@
isTapToViewExpired: isTapToView && this.get('isErased'),
isTapToViewError:
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,
authorColor,
referencedMessageNotFound,
onClick: () => this.trigger('scroll-to-message'),
};
},
getStatus(number) {
@ -851,6 +841,8 @@
this.cleanup();
},
async cleanup() {
const { messageDeleted } = window.reduxActions.conversations;
messageDeleted(this.id, this.get('conversationId'));
MessageController.unregister(this.id);
this.unload();
await this.deleteData();
@ -2193,74 +2185,5 @@
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) {
// eslint-disable-next-line no-await-in-loop
const collection = await window.Signal.Data.getMessagesByConversation(
const collection = await window.Signal.Data.getOlderMessagesByConversation(
conversation.id,
{
limit: CHUNK_SIZE,

View File

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

View File

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

View File

@ -152,7 +152,7 @@
silent: !status.shouldPlayNotificationSound,
});
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
// 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({
className: 'conversation-stack',
lastConversation: null,
open(conversation) {
open(conversation, messageId) {
const id = `conversation-${conversation.cid}`;
if (id !== this.el.firstChild.id) {
this.$el
.first()
.find('video, audio')
.each(function pauseMedia() {
this.pause();
});
let $el = this.$(`#${id}`);
if ($el === null || $el.length === 0) {
const view = new Whisper.ConversationView({
model: conversation,
window: this.model.window,
});
// eslint-disable-next-line prefer-destructuring
$el = view.$el;
if (id !== this.el.lastChild.id) {
const view = new Whisper.ConversationView({
model: conversation,
window: this.model.window,
});
view.$el.appendTo(this.el);
if (this.lastConversation) {
this.lastConversation.trigger(
'unload',
'opened another conversation'
);
}
$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
window.dispatchEvent(new Event('resize'));
},
@ -122,11 +119,10 @@
},
setupLeftPane() {
this.leftPaneView = new Whisper.ReactWrapperView({
JSX: Signal.State.Roots.createLeftPane(window.reduxStore),
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);
},
startConnectionListener() {
@ -194,7 +190,7 @@
openConversationExternal(id, messageId);
}
this.conversation_stack.open(conversation);
this.conversation_stack.open(conversation, messageId);
this.focusConversation();
},
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",
"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 .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
@ -169,6 +169,7 @@
"mocha-testcheck": "1.0.0-rc.0",
"node-sass-import-once": "1.2.0",
"nyc": "11.4.1",
"patch-package": "6.1.2",
"prettier": "1.12.0",
"react-docgen-typescript": "1.2.6",
"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() {
background-color: $color-black;
background-color: $color-gray-95;
}
}
@ -66,24 +66,21 @@
}
.main.panel {
.discussion-container {
.timeline-placeholder {
flex-grow: 1;
position: relative;
max-width: 100%;
margin: 0;
margin-bottom: 10px;
.bar-container {
height: 5px;
}
.message-list {
.timeline-wrapper {
-webkit-padding-start: 0px;
position: absolute;
top: 0;
height: 100%;
width: 100%;
margin: 0;
padding: 10px 0 0 0;
padding: 0;
overflow-y: auto;
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 {
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 {
display: flex;
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%;
}
.conversation-stack {
.conversation {
display: none;
}
.conversation:first-child {
display: block;
}
}
.tool-bar {
color: $color-light-90;

View File

@ -8,6 +8,22 @@
// 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 {
position: relative;
display: inline-flex;
@ -39,15 +55,19 @@
// Spec: container < 438px
.module-message--incoming {
margin-left: 0;
margin-left: 16px;
margin-right: 32px;
}
.module-message--outgoing {
float: right;
margin-right: 0;
margin-right: 16px;
margin-left: 32px;
}
.module-message--incoming.module-message--group {
margin-left: 44px;
}
.module-message__buttons {
position: absolute;
top: 0;
@ -165,6 +185,37 @@
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
.module-message__container--incoming {
background-color: $color-conversation-grey;
@ -704,10 +755,10 @@
.module-message__metadata__status-icon--sending {
@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% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
@ -842,6 +893,9 @@
}
.module-quote {
// To leave room for image thumbnail
min-height: 54px;
position: relative;
border-radius: 4px;
border-top-left-radius: 10px;
@ -1286,6 +1340,8 @@
.module-group-notification {
margin-top: 14px;
margin-left: 1em;
margin-right: 1em;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.3px;
@ -2420,6 +2476,13 @@
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 {
display: inline-flex;
flex-direction: row;
@ -2999,9 +3062,16 @@
// 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.
.module-spinner__container--small {
height: 24px;
width: 24px;
}
.module-spinner__circle--small {
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
-webkit-mask-size: 100%;
height: 24px;
width: 24px;
}
.module-spinner__arc--small {
-webkit-mask: url('../images/spinner-24.svg') no-repeat center;
@ -3023,6 +3093,8 @@
.module-spinner__arc--mini {
-webkit-mask: url('../images/spinner-24.svg') no-repeat center;
-webkit-mask-size: 100%;
height: 14px;
width: 14px;
}
.module-spinner__circle--incoming {
@ -3032,6 +3104,13 @@
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-message-body__highlight {
@ -3306,10 +3385,31 @@
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 {
height: 100%;
overflow: hidden;
}
.module-timeline__message-container {
@ -4686,13 +4786,35 @@
.module-countdown {
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;
stroke: $color-white;
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
@ -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
.react-contextmenu {
@ -5016,11 +5202,9 @@
// Spec: container < 438px
.module-message--incoming {
margin-left: 0;
margin-right: auto;
}
.module-message--outgoing {
margin-right: 0;
margin-left: auto;
}
@ -5051,11 +5235,9 @@
}
.module-message--incoming {
margin-left: 0;
margin-right: auto;
}
.module-message--outgoing {
margin-right: 0;
margin-left: auto;
}

View File

@ -1509,6 +1509,13 @@ body.dark-theme {
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 {

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/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/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/recorder_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/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/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/clear_data_view.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/timestamp_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/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>

View File

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

View File

@ -72,22 +72,6 @@ describe('Conversation', () => {
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 () => {
const convo = new Whisper.ConversationCollection().add({
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'}
unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000}
isTyping={true}
typingContact={{
name: 'Someone Here',
}}
onClick={result => console.log('onClick', result)}
i18n={util.i18n}
/>
@ -164,7 +166,9 @@
conversationType={'direct'}
unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000}
isTyping={true}
typingContact={{
name: 'Someone Here',
}}
lastMessage={{
status: 'read',
}}

View File

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

View File

@ -73,16 +73,25 @@ export class Countdown extends React.Component<Props, State> {
const strokeDashoffset = ratio * CIRCUMFERENCE;
return (
<svg className="module-countdown" viewBox="0 0 24 24">
<path
d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z"
className="module-countdown__path"
style={{
strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`,
strokeDashoffset,
}}
/>
</svg>
<div className="module-countdown">
<svg viewBox="0 0 24 24">
<path
d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z"
className="module-countdown__back-path"
style={{
strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`,
}}
/>
<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,
PropsData as ConversationListItemPropsType,
} from './ConversationListItem';
import {
MessageSearchResult,
PropsData as MessageSearchResultPropsType,
} from './MessageSearchResult';
import { MessageSearchResult } from './MessageSearchResult';
import { StartNewConversation } from './StartNewConversation';
import { LocalizerType } from '../types/Util';

View File

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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,7 @@ interface Props {
withContentBelow?: boolean;
bottomOverlay?: boolean;
isSticker?: boolean;
isSelected?: boolean;
stickerSize?: number;
i18n: LocalizerType;
@ -37,6 +38,7 @@ export class ImageGrid extends React.Component<Props> {
bottomOverlay,
i18n,
isSticker,
isSelected,
stickerSize,
onError,
onClick,
@ -83,6 +85,7 @@ export class ImageGrid extends React.Component<Props> {
curveBottomRight={curveBottomRight}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
isSelected={isSelected}
height={finalHeight}
width={finalWidth}
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
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const STICKER_SIZE = 128;
const SELECTED_TIMEOUT = 1000;
interface LinkPreviewType {
title: string;
@ -54,6 +55,8 @@ export type PropsData = {
text?: string;
textPending?: boolean;
isSticker: boolean;
isSelected: boolean;
isSelectedCounter: number;
direction: 'incoming' | 'outgoing';
timestamp: number;
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
@ -97,6 +100,8 @@ type PropsHousekeeping = {
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
replyToMessage: (id: string) => void;
retrySend: (id: string) => void;
deleteMessage: (id: string) => void;
@ -120,11 +125,10 @@ export type PropsActions = {
displayTapToViewMessage: (messageId: string) => unknown;
openLink: (url: string) => void;
scrollToMessage: (
scrollToQuotedMessage: (
options: {
author: string;
sentAt: number;
referencedMessageNotFound: boolean;
}
) => void;
};
@ -135,6 +139,9 @@ interface State {
expiring: boolean;
expired: boolean;
imageBroken: boolean;
isSelected: boolean;
prevSelectedCounter: number;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
@ -148,6 +155,7 @@ export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
public expirationCheckInterval: any;
public expiredTimeout: any;
public selectedTimeout: any;
public constructor(props: Props) {
super(props);
@ -160,10 +168,30 @@ export class Message extends React.PureComponent<Props, State> {
expiring: false,
expired: 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() {
this.startSelectedTimer();
const { expirationLength } = this.props;
if (!expirationLength) {
return;
@ -180,6 +208,9 @@ export class Message extends React.PureComponent<Props, State> {
}
public componentWillUnmount() {
if (this.selectedTimeout) {
clearInterval(this.selectedTimeout);
}
if (this.expirationCheckInterval) {
clearInterval(this.expirationCheckInterval);
}
@ -189,9 +220,26 @@ export class Message extends React.PureComponent<Props, State> {
}
public componentDidUpdate() {
this.startSelectedTimer();
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() {
const now = Date.now();
const { isExpired, expirationTimestamp, expirationLength } = this.props;
@ -379,7 +427,7 @@ export class Message extends React.PureComponent<Props, State> {
isSticker,
text,
} = this.props;
const { imageBroken } = this.state;
const { imageBroken, isSelected } = this.state;
if (!attachments || !attachments[0]) {
return null;
@ -422,6 +470,7 @@ export class Message extends React.PureComponent<Props, State> {
withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow}
isSticker={isSticker}
isSelected={isSticker && isSelected}
stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay}
i18n={i18n}
@ -622,7 +671,7 @@ export class Message extends React.PureComponent<Props, State> {
disableScroll,
i18n,
quote,
scrollToMessage,
scrollToQuotedMessage,
} = this.props;
if (!quote) {
@ -633,15 +682,14 @@ export class Message extends React.PureComponent<Props, State> {
conversationType === 'group' && direction === 'incoming';
const quoteColor =
direction === 'incoming' ? authorColor : quote.authorColor;
const { referencedMessageNotFound } = quote;
const clickHandler = disableScroll
? undefined
: () => {
scrollToMessage({
scrollToQuotedMessage({
author: quote.authorId,
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
public render() {
const {
authorPhoneNumber,
authorColor,
attachments,
conversationType,
direction,
displayTapToViewMessage,
id,
@ -1211,6 +1271,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp,
} = this.props;
const { expired, expiring, imageBroken } = this.state;
const isAttachmentPending = this.isAttachmentPending();
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
@ -1236,7 +1297,8 @@ export class Message extends React.PureComponent<Props, State> {
className={classNames(
'module-message',
`module-message--${direction}`,
expiring ? 'module-message--expired' : null
expiring ? 'module-message--expired' : null,
conversationType === 'group' ? 'module-message--group' : null
)}
>
{this.renderError(direction === 'incoming')}
@ -1271,6 +1333,7 @@ export class Message extends React.PureComponent<Props, State> {
>
{this.renderAuthor()}
{this.renderContents()}
{this.renderSelectionHighlight()}
{this.renderAvatar()}
</div>
{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 classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';

View File

@ -1,9 +1,9 @@
### None
### No new messages
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={0}
withNewMessages={false}
conversationId="id-1"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
@ -11,28 +11,15 @@
</util.ConversationContext>
```
### One
### With new messages
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={1}
withNewMessages={true}
conversationId="id-2"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
/>
</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';
type Props = {
count: number;
withNewMessages: boolean;
conversationId: string;
scrollDown: (conversationId: string) => void;
@ -14,21 +14,17 @@ type Props = {
export class ScrollDownButton extends React.Component<Props> {
public render() {
const { conversationId, count, i18n, scrollDown } = this.props;
let altText = i18n('scrollDown');
if (count > 1) {
altText = i18n('messagesBelow');
} else if (count === 1) {
altText = i18n('messageBelow');
}
const { conversationId, withNewMessages, i18n, scrollDown } = this.props;
const altText = withNewMessages
? i18n('messagesBelow')
: i18n('scrollDown');
return (
<div className="module-scroll-down">
<button
className={classNames(
'module-scroll-down__button',
count > 0 ? 'module-scroll-down__button--new-messages' : null
withNewMessages ? 'module-scroll-down__button--new-messages' : null
)}
onClick={() => {
scrollDown(conversationId);

View File

@ -1,5 +1,7 @@
```javascript
const itemLookup = {
## With oldest and newest
```jsx
window.itemLookup = {
'id-1': {
type: 'message',
data: {
@ -15,12 +17,24 @@ const itemLookup = {
type: 'message',
data: {
id: 'id-2',
conversationType: 'group',
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'green',
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': {
type: 'message',
data: {
@ -155,25 +169,186 @@ const itemLookup = {
},
};
const actions = {
window.actions = {
// For messages
downloadAttachment: options => console.log('onDownload', options),
replyToitem: id => console.log('onReply', id),
showMessageDetail: id => console.log('onShowDetail', 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 renderItem = id => {
const item = itemLookup[id];
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,
// Because we can't use ...item syntax
return React.createElement(
TimelineItem,
util._.merge({ item, i18n: util.i18n }, actions)
);
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
};
<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>;
```

View File

@ -1,3 +1,4 @@
import { debounce, isNumber } from 'lodash';
import React from 'react';
import {
AutoSizer,
@ -6,24 +7,64 @@ import {
List,
} from 'react-virtualized';
import { ScrollDownButton } from './ScrollDownButton';
import { LocalizerType } from '../../types/Util';
import { PropsActions as MessageActionsType } from './Message';
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>;
renderItem: (id: string) => JSX.Element;
loadCountdownStart?: number;
messageHeightChanges: boolean;
oldestUnreadIndex?: number;
resetCounter: number;
scrollToIndex?: number;
scrollToIndexCounter: number;
totalUnread: number;
};
type PropsHousekeeping = {
type PropsHousekeepingType = {
id: string;
unreadCount?: number;
typingContact?: Object;
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
type RowRendererParamsType = {
@ -34,37 +75,407 @@ type RowRendererParamsType = {
parent: 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({
defaultHeight: 85,
defaultHeight: 64,
fixedWidth: true,
});
public mostRecentWidth = 0;
public mostRecentHeight = 0;
public offsetFromBottom: number | undefined = 0;
public resizeAllFlag = false;
public listRef = React.createRef<any>();
public visibleRows: VisibleRowsType | undefined;
public loadCountdownTimeout: any;
public componentDidUpdate(prevProps: Props) {
if (this.resizeAllFlag) {
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
this.recomputeRowHeights();
} else if (this.props.items !== prevProps.items) {
const index = prevProps.items.length;
this.cellSizeCache.clear(index, 0);
this.recomputeRowHeights(index);
}
constructor(props: Props) {
super(props);
const { scrollToIndex } = this.props;
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
this.state = {
atBottom: true,
atTop: false,
oneTimeScrollRow,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false,
};
}
public resizeAll = () => {
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
public static getDerivedStateFromProps(props: Props, state: State): State {
if (
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) => {
if (this.listRef && this.listRef) {
this.listRef.current.recomputeRowHeights(index);
public getGrid = () => {
const list = this.getList();
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 = ({
@ -73,8 +484,62 @@ export class Timeline extends React.PureComponent<Props> {
parent,
style,
}: RowRendererParamsType) => {
const { items, renderItem } = this.props;
const messageId = items[index];
const {
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 (
<CellMeasurer
@ -85,16 +550,277 @@ export class Timeline extends React.PureComponent<Props> {
rowIndex={index}
width={this.mostRecentWidth}
>
<div className="module-timeline__message-container" style={style}>
{renderItem(messageId)}
</div>
{rowContents}
</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;
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 (
<div className="module-timeline">
<AutoSizer>
@ -103,26 +829,41 @@ export class Timeline extends React.PureComponent<Props> {
this.resizeAllFlag = true;
setTimeout(this.resizeAll, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
this.mostRecentWidth = width;
this.mostRecentHeight = height;
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// This also registers us with parent InfiniteLoader
// onRowsRendered={onRowsRendered}
overscanRowCount={0}
onScroll={this.onScroll as any}
overscanRowCount={10}
ref={this.listRef}
rowCount={items.length}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
width={width}
/>
);
}}
</AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div>
);
}

View File

@ -1,3 +1,51 @@
### A plain message
```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,
PropsData as MessageProps,
} from './Message';
import {
PropsActions as UnsupportedMessageActionsType,
PropsData as UnsupportedMessageProps,
UnsupportedMessage,
} from './UnsupportedMessage';
import {
PropsData as TimerNotificationProps,
TimerNotification,
@ -29,6 +34,10 @@ type MessageType = {
type: 'message';
data: MessageProps;
};
type UnsupportedMessageType = {
type: 'unsupportedMessage';
data: UnsupportedMessageProps;
};
type TimerNotificationType = {
type: 'timerNotification';
data: TimerNotificationProps;
@ -49,22 +58,26 @@ type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
};
export type TimelineItemType =
| MessageType
| UnsupportedMessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
type PropsData = {
item:
| MessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
item?: TimelineItemType;
};
type PropsHousekeeping = {
i18n: LocalizerType;
};
type PropsActions = MessageActionsType & SafetyNumberActionsType;
type PropsActions = MessageActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
type Props = PropsData & PropsHousekeeping & PropsActions;
@ -73,12 +86,18 @@ export class TimelineItem extends React.PureComponent<Props> {
const { item, i18n } = this.props;
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') {
return <Message {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'unsupportedMessage') {
return <UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'timerNotification') {
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 { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
export type PropsData = {
type: 'fromOther' | 'fromMe' | 'fromSync';
phoneNumber: string;
@ -63,7 +61,9 @@ export class TimerNotification extends React.Component<Props> {
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
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}>
<li>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -28,8 +28,8 @@ function getDecember1159() {
text="500ms ago - all below 1 minute are 'now'"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -38,8 +38,8 @@ function getDecember1159() {
text="Five seconds ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -48,8 +48,8 @@ function getDecember1159() {
text="30 seconds ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -58,8 +58,8 @@ function getDecember1159() {
text="One minute ago - in minutes"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -68,8 +68,8 @@ function getDecember1159() {
text="30 minutes ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -78,8 +78,8 @@ function getDecember1159() {
text="45 minutes ago (used to round up to 1 hour with moment)"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -88,8 +88,8 @@ function getDecember1159() {
text="One hour ago - in hours"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -98,8 +98,8 @@ function getDecember1159() {
text="12:01am today"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -108,8 +108,8 @@ function getDecember1159() {
text="11:59pm yesterday - adds day name"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -118,8 +118,8 @@ function getDecember1159() {
text="24 hours ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -128,8 +128,8 @@ function getDecember1159() {
text="Two days ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -138,8 +138,8 @@ function getDecember1159() {
text="Seven days ago - adds month"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -148,8 +148,8 @@ function getDecember1159() {
text="Thirty days ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -158,8 +158,8 @@ function getDecember1159() {
text="January 1st at 12:01am"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -168,8 +168,8 @@ function getDecember1159() {
text="December 31st at 11:59pm - adds year"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@ -178,6 +178,6 @@ function getDecember1159() {
text="One year ago"
i18n={util.i18n}
/>
</li>
</div>
</util.ConversationContext>;
```

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,13 @@
export function getMessageModel(attributes: any) {
export function getSearchResultsProps(attributes: any) {
// @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 { omit } from 'lodash';
import {
difference,
fromPairs,
intersection,
omit,
orderBy,
pick,
uniq,
values,
without,
} from 'lodash';
import { trigger } from '../../shims/events';
import { NoopActionType } from './noop';
@ -48,29 +56,65 @@ export type ConversationType = {
lastUpdated: number;
unreadCount: number;
isSelected: boolean;
isTyping: boolean;
typingContact?: {
avatarPath?: string;
color: string;
name?: string;
phoneNumber: string;
profileName?: string;
};
};
export type ConversationLookupType = {
[key: string]: ConversationType;
};
export type MessageType = {
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 = {
[key: string]: MessageType;
};
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?
// We have the infrastructure for it now...
messages: Array<string>;
heightChangeMessageIds: Array<string>;
isLoadingMessages: boolean;
isNearBottom?: boolean;
loadCountdownStart?: number;
messageIds: Array<string>;
metrics: MessageMetricsType;
resetCounter: number;
scrollToMessageId?: string;
scrollToMessageCounter: number;
};
export type MessagesByConversationType = {
[key: string]: ConversationMessageType;
[key: string]: ConversationMessageType | null;
};
export type ConversationsStateType = {
conversationLookup: ConversationLookupType;
selectedConversation?: string;
selectedMessage?: string;
selectedMessageCounter: number;
showArchived: boolean;
// Note: it's very important that both of these locations are always kept up to date
@ -100,15 +144,91 @@ type ConversationRemovedActionType = {
id: string;
};
};
type ConversationUnloadedActionType = {
type: 'CONVERSATION_UNLOADED';
payload: {
id: string;
};
};
export type RemoveAllConversationsActionType = {
type: 'CONVERSATIONS_REMOVE_ALL';
payload: null;
};
export type MessageExpiredActionType = {
type: 'MESSAGE_EXPIRED';
export type MessageChangedActionType = {
type: 'MESSAGE_CHANGED';
payload: {
id: 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 = {
@ -128,14 +248,24 @@ type ShowArchivedConversationsActionType = {
};
export type ConversationActionType =
| AnyAction
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationUnloadedActionType
| RemoveAllConversationsActionType
| MessageExpiredActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
| MessagesResetActionType
| SetMessagesLoadingActionType
| SetIsNearBottomActionType
| SetLoadCountdownStartActionType
| ClearChangedMessagesActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType
| MessageExpiredActionType
| MessageDeletedActionType
| SelectedConversationChangedActionType
| ShowInboxActionType
| ShowArchivedConversationsActionType;
@ -146,8 +276,19 @@ export const actions = {
conversationAdded,
conversationChanged,
conversationRemoved,
conversationUnloaded,
removeAllConversations,
messageExpired,
messageDeleted,
messageChanged,
messagesAdded,
messagesReset,
setMessagesLoading,
setLoadCountdownStart,
setIsNearBottom,
clearChangedMessages,
clearSelectedMessage,
clearUnreadMetrics,
scrollToMessage,
openConversationInternal,
openConversationExternal,
showInbox,
@ -186,6 +327,14 @@ function conversationRemoved(id: string): ConversationRemovedActionType {
},
};
}
function conversationUnloaded(id: string): ConversationUnloadedActionType {
return {
type: 'CONVERSATION_UNLOADED',
payload: {
id,
},
};
}
function removeAllConversations(): RemoveAllConversationsActionType {
return {
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,
conversationId: string
): MessageExpiredActionType {
): MessageDeletedActionType {
return {
type: 'MESSAGE_EXPIRED',
type: 'MESSAGE_DELETED',
payload: {
id,
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
// trigger an 'openConversation' so we go through Whisper.events for all conversation
// selection.
// trigger an 'openConversation' so we go through Whisper.events for all
// conversation selection. Internal just triggers the Whisper.event, and External
// makes the changes to the store.
function openConversationInternal(
id: string,
messageId?: string
@ -251,12 +522,24 @@ function showArchivedConversations() {
function getEmptyState(): ConversationsStateType {
return {
conversationLookup: {},
showArchived: false,
messagesLookup: {},
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(
state: ConversationsStateType = getEmptyState(),
action: ConversationActionType
@ -322,11 +605,421 @@ export function reducer(
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') {
return getEmptyState();
}
if (action.type === 'MESSAGE_EXPIRED') {
// noop - for now this is only important for search
if (action.type === 'MESSAGE_CHANGED') {
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') {
const { payload } = action;

View File

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

View File

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

View File

@ -6,12 +6,16 @@ import { LocalizerType } from '../../types/Util';
import { StateType } from '../reducer';
import {
ConversationLookupType,
ConversationMessageType,
ConversationsStateType,
ConversationType,
MessageLookupType,
MessagesByConversationType,
MessageType,
} 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';
@ -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(
getConversations,
(state: ConversationsStateType): boolean => {
@ -160,9 +182,12 @@ export const getMe = createSelector(
);
// This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps()
// Blockers:
// 1) contactTypingTimers - that UI-only state needs to be moved to redux
// is currently in models/conversation.getProps()
// What needs to happen to pull that selector logic here?
// 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(
conversation: ConversationType
// regionCode: string,
@ -180,6 +205,8 @@ export const getCachedSelectorForConversation = createSelector(
getRegionCode,
getUserNumber,
(): CachedConversationSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
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.
// Blockers:
// 1) it's a lot of code to pull over - ~500 lines
// 2) a couple places still rely on all that code - will need to move these to Roots:
// - quote compose
// - message details
// For now we use a shim, as selector logic is still happening in the Backbone Model.
// What needs to happen to pull that selector logic here?
// 1) translate ~500 lines of selector logic into TypeScript
// 2) other places still rely on that prop-gen code - need to put these under Roots:
// - quote compose
// - message details
export function _messageSelector(
message: MessageType
// ourNumber: string,
// regionCode: string,
// conversation?: ConversationType,
// sender?: ConversationType,
// quoted?: ConversationType
): MessageType {
return message;
message: MessageType,
// @ts-ignore
ourNumber: string,
// @ts-ignore
regionCode: string,
// @ts-ignore
conversation?: ConversationType,
// @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
// 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(
getRegionCode,
getUserNumber,
(): CachedMessageSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_messageSelector, { max: 500 });
}
);
type GetMessageByIdType = (id: string) => MessageType | undefined;
type GetMessageByIdType = (id: string) => TimelineItemType | undefined;
export const getMessageSelector = createSelector(
getCachedSelectorForMessage,
getMessages,
getSelectedMessage,
getConversationSelector,
getRegionCode,
getUserNumber,
(
selector: CachedMessageSelectorType,
lookup: MessageLookupType
messageSelector: CachedMessageSelectorType,
messageLookup: MessageLookupType,
selectedMessage: SelectedMessageType | undefined,
conversationSelector: GetConversationByIdType,
regionCode: string,
ourNumber: string
): GetMessageByIdType => {
return (id: string) => {
const message = lookup[id];
const message = messageLookup[id];
if (!message) {
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 { createSelector } from 'reselect';
import { getSearchResultsProps } from '../../shims/Whisper';
import { StateType } from '../reducer';
@ -79,14 +80,16 @@ export const getSearchResults = createSelector(
),
hideMessagesHeader: false,
messages: state.messages.map(message => {
const props = getSearchResultsProps(message);
if (message.id === selectedMessage) {
return {
...message,
...props,
isSelected: true,
};
}
return message;
return props;
}),
regionCode: regionCode,
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
const FilteredSmartMainHeader = SmartMainHeader as any;
function renderMainHeader(): JSX.Element {
return <FilteredSmartMainHeader />;
}
const mapStateToProps = (state: StateType) => {
const showSearch = isSearching(state);
@ -25,7 +29,7 @@ const mapStateToProps = (state: StateType) => {
searchResults,
showArchived: getShowArchived(state),
i18n: getIntl(state),
renderMainHeader: () => <FilteredSmartMainHeader />,
renderMainHeader,
};
};

View File

@ -1,3 +1,4 @@
import { pick } from 'lodash';
import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
@ -5,32 +6,59 @@ import { Timeline } from '../../components/conversation/Timeline';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
import {
getConversationMessagesSelector,
getConversationSelector,
} from '../selectors/conversations';
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()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartTimelineItem = SmartTimelineItem as any;
const FilteredSmartTypingBubble = SmartTypingBubble as any;
const FilteredSmartLastSeenIndicator = SmartLastSeenIndicator as any;
const FilteredSmartTimelineLoadingRow = SmartTimelineLoadingRow as any;
type ExternalProps = {
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) => {
const { id } = props;
function renderItem(messageId: string, actionProps: Object): JSX.Element {
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 conversation = conversationSelector(id);
const items: Array<string> = [];
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, ...actions } = props;
const conversation = getConversationSelector(state)(id);
const conversationMessages = getConversationMessagesSelector(state)(id);
return {
...conversation,
items,
id,
...pick(conversation, ['unreadCount', 'typingContact']),
...conversationMessages,
i18n: getIntl(state),
renderTimelineItem: (messageId: string) => {
return <FilteredSmartTimelineItem id={messageId} />;
},
renderItem,
renderLastSeenIndicator,
renderLoadingRow,
renderTypingBubble,
...actions,
};
};

View File

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

View File

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

View File

@ -164,17 +164,17 @@
"rule": "jQuery-load(",
"path": "js/conversation_controller.js",
"line": " async load() {",
"lineNumber": 178,
"lineNumber": 169,
"reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z"
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-load(",
"path": "js/conversation_controller.js",
"line": " this._initialPromise = load();",
"lineNumber": 213,
"lineNumber": 204,
"reasonCategory": "falseMatch",
"updated": "2018-10-02T21:00:44.007Z"
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-$(",
@ -363,8 +363,8 @@
"line": " this.$el.append(this.contactView.el);",
"lineNumber": 46,
"reasonCategory": "usageTrusted",
"updated": "2018-10-02T21:18:39.026Z",
"reasonDetail": "Operating on previously-existing DOM elements"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
@ -474,148 +474,139 @@
"updated": "2018-09-15T00:38:04.183Z"
},
{
"rule": "jQuery-$(",
"rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js",
"line": " let $el = this.$(`#${id}`);",
"lineNumber": 34,
"line": " view.$el.appendTo(this.el);",
"lineNumber": 32,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"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"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.message').text(message);",
"lineNumber": 61,
"lineNumber": 58,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " el: this.$('.conversation-stack'),",
"lineNumber": 78,
"lineNumber": 75,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js",
"line": " this.appLoadingScreen.$el.prependTo(this.el);",
"lineNumber": 85,
"lineNumber": 82,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-append(",
"path": "js/views/inbox_view.js",
"line": " .append(this.networkStatusView.render().el);",
"lineNumber": 100,
"lineNumber": 97,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-prependTo(",
"path": "js/views/inbox_view.js",
"line": " banner.$el.prependTo(this.$el);",
"lineNumber": 104,
"lineNumber": 101,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 110,
"lineNumber": 107,
"reasonCategory": "usageTrusted",
"updated": "2019-05-10T00:25:51.515Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 130,
"lineNumber": 126,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-append(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 130,
"lineNumber": 126,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.placeholder').length) {",
"lineNumber": 171,
"lineNumber": 167,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('#header, .gutter').addClass('inactive');",
"lineNumber": 175,
"lineNumber": 171,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation-stack').addClass('inactive');",
"lineNumber": 179,
"lineNumber": 175,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .menu').trigger('close');",
"lineNumber": 181,
"lineNumber": 177,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 201,
"lineNumber": 197,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 204,
"lineNumber": 200,
"reasonCategory": "usageTrusted",
"updated": "2019-03-08T23:49:08.796Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
@ -722,8 +713,8 @@
"line": " new QRCode(this.$('.qr')[0]).makeCode(",
"lineNumber": 39,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-wrap(",
@ -731,7 +722,7 @@
"line": " dcodeIO.ByteBuffer.wrap(this.ourKey).toString('base64')",
"lineNumber": 40,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-insertBefore(",
@ -739,8 +730,8 @@
"line": " dialog.$el.insertBefore(this.el);",
"lineNumber": 75,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
@ -748,8 +739,8 @@
"line": " this.$('button.verify').attr('disabled', true);",
"lineNumber": 79,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
@ -757,8 +748,8 @@
"line": " this.$('button.verify').removeAttr('disabled');",
"lineNumber": 110,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-append(",
@ -778,105 +769,6 @@
"updated": "2018-09-15T00:38:04.183Z",
"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-$(",
"path": "js/views/phone-input-view.js",
@ -1453,6 +1345,45 @@
"reasonCategory": "falseMatch",
"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(",
"path": "node_modules/archiver-utils/node_modules/lodash/after.js",
@ -3590,20 +3521,36 @@
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/extglob/index.js",
"line": " o[id] = wrap(inner, prefix, opts.escape);",
"lineNumber": 85,
"rule": "jQuery-load(",
"path": "node_modules/extglob/node_modules/debug/src/browser.js",
"line": "function load() {",
"lineNumber": 150,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
"updated": "2019-07-31T00:19:18.696Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/extglob/index.js",
"line": "function wrap(inner, prefix, esc) {",
"lineNumber": 119,
"rule": "jQuery-load(",
"path": "node_modules/extglob/node_modules/debug/src/browser.js",
"line": "exports.enable(load());",
"lineNumber": 168,
"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",
@ -5219,46 +5166,6 @@
"reasonCategory": "falseMatch",
"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",
"path": "node_modules/min-document/serialize.js",
@ -7161,6 +7068,62 @@
"reasonCategory": "falseMatch",
"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",
"path": "node_modules/thenify/index.js",
@ -7849,8 +7812,8 @@
"line": " this.menuTriggerRef = react_1.default.createRef();",
"lineNumber": 14,
"reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to trigger menu display"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Used to reference popup menu"
},
{
"rule": "React-createRef",
@ -7858,17 +7821,17 @@
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 59,
"reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to trigger menu display"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Used to reference popup menu"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Timeline.js",
"line": " this.listRef = react_1.default.createRef();",
"lineNumber": 17,
"lineNumber": 27,
"reasonCategory": "usageTrusted",
"updated": "2019-04-17T18:44:33.207Z",
"reasonDetail": "Necessary to interact with child react-virtualized/List"
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Timeline needs to interact with its child List directly"
},
{
"rule": "jQuery-wrap(",

View File

@ -93,7 +93,13 @@
"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
"no-implicit-dependencies": [true, ["dashdash", "electron"]],

View File

@ -313,6 +313,11 @@
dependencies:
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:
version "1.1.0"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f"
@ -2067,7 +2072,7 @@ cross-spawn@^4:
lru-cache "^4.0.1"
which "^1.2.9"
cross-spawn@^6.0.0:
cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
@ -3526,6 +3531,14 @@ find-up@^3.0.0:
dependencies:
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:
version "0.3.0"
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"
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:
version "7.0.1"
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"
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:
version "5.0.15"
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"
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:
version "1.3.1"
resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439"
@ -6935,6 +6976,25 @@ pascalcase@^0.1.1:
version "0.1.1"
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:
version "0.0.0"
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:
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:
version "2.4.5"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da"
@ -8791,6 +8858,11 @@ slash@^1.0.0:
version "1.0.0"
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:
version "1.0.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"