Receive support for View Once photos

This commit is contained in:
Scott Nonnenberg 2019-06-26 12:33:13 -07:00
parent fccf1eec30
commit e62a1a7812
38 changed files with 1937 additions and 102 deletions

View File

@ -1745,6 +1745,11 @@
"description":
"Shown in notifications and in the left pane when a message has features too new for this signal install."
},
"message--getDescription--disappearing-photo": {
"message": "Disappearing photo",
"description":
"Shown in notifications and in the left pane when a message is a disappearing photo."
},
"stickers--toast--InstallFailed": {
"message": "Sticker pack could not be installed",
"description":
@ -1901,5 +1906,20 @@
"message": "Update Signal",
"description":
"Text for a button which will take user to Signal download page"
},
"Message--tap-to-view-expired": {
"message": "Viewed",
"description":
"Text shown on messages with with individual timers, after user has viewed it"
},
"Message--tap-to-view--outgoing": {
"message": "Photo",
"description":
"Text shown on outgoing messages with with individual timers (inaccessble)"
},
"Message--tap-to-view--incoming": {
"message": "View Photo",
"description":
"Text shown on messages with with individual timers, before user has viewed it"
}
}

View File

@ -94,6 +94,9 @@ module.exports = {
getOutgoingWithoutExpiresAt,
getNextExpiringMessage,
getMessagesByConversation,
getNextTapToViewMessageToExpire,
getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase,
getUnprocessedCount,
getAllUnprocessed,
@ -868,6 +871,87 @@ async function updateToSchemaVersion15(currentVersion, instance) {
}
}
async function updateToSchemaVersion16(currentVersion, instance) {
if (currentVersion >= 16) {
return;
}
console.log('updateToSchemaVersion16: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
await instance.run(
`ALTER TABLE messages
ADD COLUMN messageTimer INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN messageTimerStart INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN messageTimerExpiresAt INTEGER;`
);
await instance.run(
`ALTER TABLE messages
ADD COLUMN isErased INTEGER;`
);
await instance.run(`CREATE INDEX messages_message_timer ON messages (
messageTimer,
messageTimerStart,
messageTimerExpiresAt,
isErased
) WHERE messageTimer IS NOT NULL;`);
// Updating full-text triggers to avoid anything with a messageTimer set
await instance.run('DROP TRIGGER messages_on_insert;');
await instance.run('DROP TRIGGER messages_on_delete;');
await instance.run('DROP TRIGGER messages_on_update;');
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
WHEN new.messageTimer IS NULL
BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE id = old.id;
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
WHEN new.messageTimer IS NULL
BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run('PRAGMA schema_version = 16;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion16: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -884,6 +968,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion13,
updateToSchemaVersion14,
updateToSchemaVersion15,
updateToSchemaVersion16,
];
async function updateSchema(instance) {
@ -1480,6 +1565,10 @@ async function saveMessage(data, { forceSave } = {}) {
hasFileAttachments,
hasVisualMediaAttachments,
id,
isErased,
messageTimer,
messageTimerStart,
messageTimerExpiresAt,
// eslint-disable-next-line camelcase
received_at,
schemaVersion,
@ -1505,6 +1594,10 @@ async function saveMessage(data, { forceSave } = {}) {
$hasAttachments: hasAttachments,
$hasFileAttachments: hasFileAttachments,
$hasVisualMediaAttachments: hasVisualMediaAttachments,
$isErased: isErased,
$messageTimer: messageTimer,
$messageTimerStart: messageTimerStart,
$messageTimerExpiresAt: messageTimerExpiresAt,
$received_at: received_at,
$schemaVersion: schemaVersion,
$sent_at: sent_at,
@ -1517,7 +1610,9 @@ async function saveMessage(data, { forceSave } = {}) {
if (id && !forceSave) {
await db.run(
`UPDATE messages SET
id = $id,
json = $json,
body = $body,
conversationId = $conversationId,
expirationStartTimestamp = $expirationStartTimestamp,
@ -1526,7 +1621,10 @@ async function saveMessage(data, { forceSave } = {}) {
hasAttachments = $hasAttachments,
hasFileAttachments = $hasFileAttachments,
hasVisualMediaAttachments = $hasVisualMediaAttachments,
id = $id,
isErased = $isErased,
messageTimer = $messageTimer,
messageTimerStart = $messageTimerStart,
messageTimerExpiresAt = $messageTimerExpiresAt,
received_at = $received_at,
schemaVersion = $schemaVersion,
sent_at = $sent_at,
@ -1559,6 +1657,10 @@ async function saveMessage(data, { forceSave } = {}) {
hasAttachments,
hasFileAttachments,
hasVisualMediaAttachments,
isErased,
messageTimer,
messageTimerStart,
messageTimerExpiresAt,
received_at,
schemaVersion,
sent_at,
@ -1578,6 +1680,10 @@ async function saveMessage(data, { forceSave } = {}) {
$hasAttachments,
$hasFileAttachments,
$hasVisualMediaAttachments,
$isErased,
$messageTimer,
$messageTimerStart,
$messageTimerExpiresAt,
$received_at,
$schemaVersion,
$sent_at,
@ -1756,6 +1862,69 @@ async function getNextExpiringMessage() {
return map(rows, row => jsonToObject(row.json));
}
async function getNextTapToViewMessageToExpire() {
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(`
SELECT json FROM messages
WHERE
messageTimer > 0
AND messageTimerExpiresAt > 0
AND (isErased IS NULL OR isErased != 1)
ORDER BY messageTimerExpiresAt ASC
LIMIT 1;
`);
if (!rows || rows.length < 1) {
return null;
}
return jsonToObject(rows[0].json);
}
async function getNextTapToViewMessageToAgeOut() {
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(`
SELECT json FROM messages
WHERE
messageTimer > 0
AND (isErased IS NULL OR isErased != 1)
ORDER BY received_at ASC
LIMIT 1;
`);
if (!rows || rows.length < 1) {
return null;
}
return jsonToObject(rows[0].json);
}
async function getTapToViewMessagesNeedingErase() {
const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000;
const NOW = Date.now();
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(
`SELECT json FROM messages
WHERE
messageTimer > 0
AND (isErased IS NULL OR isErased != 1)
AND (
(messageTimerExpiresAt > 0
AND messageTimerExpiresAt <= $NOW)
OR
received_at <= $THIRTY_DAYS_AGO
)
ORDER BY received_at ASC;`,
{
$NOW: NOW,
$THIRTY_DAYS_AGO: THIRTY_DAYS_AGO,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function saveUnprocessed(data, { forceSave } = {}) {
const { id, timestamp, version, attempts, envelope } = data;
if (!id) {

View File

@ -482,11 +482,13 @@
<script type='text/javascript' src='js/delivery_receipts.js'></script>
<script type='text/javascript' src='js/read_receipts.js'></script>
<script type='text/javascript' src='js/read_syncs.js'></script>
<script type='text/javascript' src='js/view_syncs.js'></script>
<script type='text/javascript' src='js/libphonenumber-util.js'></script>
<script type='text/javascript' src='js/models/messages.js'></script>
<script type='text/javascript' src='js/models/conversations.js'></script>
<script type='text/javascript' src='js/models/blockedNumbers.js'></script>
<script type='text/javascript' src='js/expiring_messages.js'></script>
<script type='text/javascript' src='js/expiring_tap_to_view_messages.js'></script>
<script type='text/javascript' src='js/chromium.js'></script>
<script type='text/javascript' src='js/registration.js'></script>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Export" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M21.5,11.1l-16-9.3C4.7,1.4,4,1.8,4,2.7v18.6c0,1,0.7,1.3,1.5,0.9l16-9.3c0.5-0.2,0.7-0.7,0.6-1.2
C22,11.4,21.8,11.2,21.5,11.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Export" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M5.5,3.6L20,12L5.5,20.4V3.6 M4.8,1.6C4.3,1.6,4,2,4,2.7v18.6c0,0.7,0.3,1.1,0.8,1.1c0.2,0,0.5-0.1,0.7-0.2l16-9.3
c0.5-0.2,0.7-0.7,0.6-1.2c-0.1-0.3-0.3-0.5-0.6-0.6l-16-9.3C5.3,1.7,5.1,1.6,4.8,1.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

View File

@ -652,6 +652,7 @@
Whisper.WallClockListener.init(Whisper.events);
Whisper.ExpiringMessagesListener.init(Whisper.events);
Whisper.TapToViewMessagesListener.init(Whisper.events);
if (Whisper.Import.isIncomplete()) {
window.log.info('Import was interrupted, showing import error screen');
@ -836,6 +837,7 @@
addQueuedEventListener('configuration', onConfiguration);
addQueuedEventListener('typing', onTyping);
addQueuedEventListener('sticker-pack', onStickerPack);
addQueuedEventListener('viewSync', onViewSync);
window.Signal.AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
@ -1685,6 +1687,22 @@
throw error;
}
async function onViewSync(ev) {
const { viewedAt, source, timestamp } = ev;
window.log.info(`view sync ${source} ${timestamp}, viewed at ${viewedAt}`);
const sync = Whisper.ViewSyncs.add({
source,
timestamp,
viewedAt,
});
sync.on('remove', ev.confirm);
// Calling this directly so we can wait for completion
return Whisper.ViewSyncs.onSync(sync);
}
function onReadReceipt(ev) {
const readAt = ev.timestamp;
const { timestamp } = ev.read;

View File

@ -0,0 +1,109 @@
/* global
_,
MessageController,
Whisper
*/
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
async function eraseTapToViewMessages() {
try {
window.log.info('eraseTapToViewMessages: Loading messages...');
const messages = await window.Signal.Data.getTapToViewMessagesNeedingErase(
{
MessageCollection: Whisper.MessageCollection,
}
);
await Promise.all(
messages.map(async fromDB => {
const message = MessageController.register(fromDB.id, fromDB);
window.log.info(
'eraseTapToViewMessages: message data erased',
message.idForLogging()
);
message.trigger('erased');
await message.eraseContents();
})
);
} catch (error) {
window.log.error(
'eraseTapToViewMessages: Error erasing messages',
error && error.stack ? error.stack : error
);
}
window.log.info('eraseTapToViewMessages: complete');
}
let timeout;
async function checkTapToViewMessages() {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const THIRTY_DAYS = 30 * 24 * HOUR;
const toAgeOut = await window.Signal.Data.getNextTapToViewMessageToAgeOut({
Message: Whisper.Message,
});
const toExpire = await window.Signal.Data.getNextTapToViewMessageToExpire({
Message: Whisper.Message,
});
if (!toAgeOut && !toExpire) {
return;
}
const ageOutAt = toAgeOut
? toAgeOut.get('received_at') + THIRTY_DAYS
: Number.MAX_VALUE;
const expireAt = toExpire
? toExpire.get('messageTimerExpiresAt')
: Number.MAX_VALUE;
const nextCheck = Math.min(ageOutAt, expireAt);
Whisper.TapToViewMessagesListener.nextCheck = nextCheck;
window.log.info(
'checkTapToViewMessages: next check at',
new Date(nextCheck).toISOString()
);
let wait = nextCheck - Date.now();
// In the past
if (wait < 0) {
wait = 0;
}
// Too far in the future, since it's limited to a 32-bit value
if (wait > 2147483647) {
wait = 2147483647;
}
clearTimeout(timeout);
timeout = setTimeout(async () => {
await eraseTapToViewMessages();
checkTapToViewMessages();
}, wait);
}
const throttledCheckTapToViewMessages = _.throttle(
checkTapToViewMessages,
1000
);
Whisper.TapToViewMessagesListener = {
nextCheck: null,
init(events) {
checkTapToViewMessages();
events.on('timetravel', throttledCheckTapToViewMessages);
},
update: throttledCheckTapToViewMessages,
};
})();

View File

@ -857,11 +857,9 @@
author: contact.id,
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
attachments: await this.getQuoteAttachment(
attachments,
preview,
sticker
),
attachments: quotedMessage.isTapToView()
? [{ contentType: 'image/jpeg', fileName: null }]
: await this.getQuoteAttachment(attachments, preview, sticker),
};
},

View File

@ -470,6 +470,8 @@
const isGroup = conversation && !conversation.isPrivate();
const sticker = this.get('sticker');
const isTapToView = this.isTapToView();
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
textPending: this.get('bodyPending'),
@ -492,6 +494,12 @@
expirationLength,
expirationTimestamp,
isTapToView,
isTapToViewExpired:
isTapToView && (this.get('isErased') || this.isTapToViewExpired()),
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),
@ -506,6 +514,8 @@
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'),
@ -727,6 +737,9 @@
if (this.isUnsupportedMessage()) {
return i18n('message--getDescription--unsupported-message');
}
if (this.isTapToView()) {
return i18n('message--getDescription--disappearing-photo');
}
if (this.isGroupUpdate()) {
const groupUpdate = this.get('group_update');
if (groupUpdate.left === 'You') {
@ -841,6 +854,9 @@
async cleanup() {
MessageController.unregister(this.id);
this.unload();
await this.deleteData();
},
async deleteData() {
await deleteExternalMessageFiles(this.attributes);
const sticker = this.get('sticker');
@ -853,6 +869,154 @@
await deletePackReference(this.id, packId);
}
},
isTapToView() {
return Boolean(this.get('messageTimer'));
},
isValidTapToView() {
const body = this.get('body');
if (body) {
return false;
}
const attachments = this.get('attachments');
if (!attachments || attachments.length !== 1) {
return false;
}
const firstAttachment = attachments[0];
if (
!window.Signal.Util.GoogleChrome.isImageTypeSupported(
firstAttachment.contentType
)
) {
return false;
}
const quote = this.get('quote');
const sticker = this.get('sticker');
const contact = this.get('contact');
const preview = this.get('preview');
if (
quote ||
sticker ||
(contact && contact.length > 0) ||
(preview && preview.length > 0)
) {
return false;
}
return true;
},
isTapToViewExpired() {
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
const now = Date.now();
const receivedAt = this.get('received_at');
if (now >= receivedAt + THIRTY_DAYS) {
return true;
}
const messageTimer = this.get('messageTimer');
const messageTimerStart = this.get('messageTimerStart');
if (!messageTimerStart) {
return false;
}
const expiresAt = messageTimerStart + messageTimer * 1000;
if (now >= expiresAt) {
return true;
}
return false;
},
async startTapToViewTimer(viewedAt, options) {
const { fromSync } = options || {};
if (this.get('unread')) {
await this.markRead();
}
const messageTimer = this.get('messageTimer');
if (!messageTimer) {
window.log.warn(
`startTapToViewTimer: Message ${this.idForLogging()} has no messageTimer!`
);
return;
}
const existingTimerStart = this.get('messageTimerStart');
const messageTimerStart = Math.min(
Date.now(),
viewedAt || Date.now(),
existingTimerStart || Date.now()
);
const messageTimerExpiresAt = messageTimerStart + messageTimer * 1000;
// Because we're not using Backbone-integrated saves, we need to manually
// clear the changed fields here so our hasChanged() check below is useful.
this.changed = {};
this.set({
messageTimerStart,
messageTimerExpiresAt,
});
if (!this.hasChanged()) {
return;
}
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
if (!fromSync) {
const sender = this.getSource();
const timestamp = this.get('sent_at');
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber,
{ syncMessage: true }
);
await wrap(
textsecure.messaging.syncMessageTimerRead(
sender,
timestamp,
sendOptions
)
);
}
},
async eraseContents() {
if (this.get('isErased')) {
return;
}
window.log.info(`Erasing data for message ${this.idForLogging()}`);
try {
await this.deleteData();
} catch (error) {
window.log.error(
`Error erasing data for message ${this.idForLogging()}:`,
error && error.stack ? error.stack : error
);
}
this.set({
isErased: true,
body: '',
attachments: [],
quote: null,
contact: [],
sticker: null,
preview: [],
});
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
},
unload() {
if (this.quotedMessage) {
this.quotedMessage = null;
@ -1581,6 +1745,16 @@
quote.referencedMessageNotFound = true;
return message;
}
if (found.isTapToView()) {
quote.text = null;
quote.attachments = [
{
contentType: 'image/jpeg',
},
];
return message;
}
const queryMessage = MessageController.register(found.id, found);
quote.text = queryMessage.get('body');
@ -1765,6 +1939,7 @@
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
messageTimer: dataMessage.messageTimer,
preview,
requiredProtocolVersion:
dataMessage.requiredProtocolVersion ||
@ -1925,7 +2100,34 @@
message.set({ id });
MessageController.register(message.id, message);
if (!message.isUnsupportedMessage()) {
if (message.isTapToView() && type === 'outgoing') {
await message.eraseContents();
}
if (
type === 'incoming' &&
message.isTapToView() &&
!message.isValidTapToView()
) {
window.log.warn(
`Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.`
);
message.set({
isTapToViewInvalid: true,
});
await message.eraseContents();
}
// Check for out-of-order view syncs
if (type === 'incoming' && message.isTapToView()) {
const viewSync = Whisper.ViewSyncs.forMessage(message);
if (viewSync) {
await Whisper.ViewSyncs.onSync(viewSync);
}
}
if (message.isUnsupportedMessage()) {
await message.eraseContents();
} else {
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
@ -2017,8 +2219,10 @@
};
};
Whisper.Message.refreshExpirationTimer = () =>
Whisper.Message.updateTimers = () => {
Whisper.ExpiringMessagesListener.update();
Whisper.TapToViewMessagesListener.update();
};
Whisper.MessageCollection = Backbone.Collection.extend({
model: Whisper.Message,

View File

@ -715,7 +715,7 @@ async function exportConversation(conversation, options = {}) {
count += 1;
// skip message if it is disappearing, no matter the amount of time left
if (message.expireTimer) {
if (message.expireTimer || message.messageTimer) {
// eslint-disable-next-line no-continue
continue;
}

View File

@ -122,6 +122,9 @@ module.exports = {
getOutgoingWithoutExpiresAt,
getNextExpiringMessage,
getMessagesByConversation,
getNextTapToViewMessageToExpire,
getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase,
getUnprocessedCount,
getAllUnprocessed,
@ -674,7 +677,7 @@ async function getMessageCount() {
async function saveMessage(data, { forceSave, Message } = {}) {
const id = await channels.saveMessage(_cleanData(data), { forceSave });
Message.refreshExpirationTimer();
Message.updateTimers();
return id;
}
@ -839,6 +842,27 @@ async function getNextExpiringMessage({ MessageCollection }) {
return new MessageCollection(messages);
}
async function getNextTapToViewMessageToExpire({ Message }) {
const message = await channels.getNextTapToViewMessageToExpire();
if (!message) {
return null;
}
return new Message(message);
}
async function getNextTapToViewMessageToAgeOut({ Message }) {
const message = await channels.getNextTapToViewMessageToAgeOut();
if (!message) {
return null;
}
return new Message(message);
}
async function getTapToViewMessagesNeedingErase({ MessageCollection }) {
const messages = await channels.getTapToViewMessagesNeedingErase();
return new MessageCollection(messages);
}
// Unprocessed
async function getUnprocessedCount() {

67
js/view_syncs.js Normal file
View File

@ -0,0 +1,67 @@
/* global
Backbone,
Whisper,
MessageController
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.ViewSyncs = new (Backbone.Collection.extend({
forMessage(message) {
const sync = this.findWhere({
source: message.get('source'),
timestamp: message.get('sent_at'),
});
if (sync) {
window.log.info('Found early view sync for message');
this.remove(sync);
return sync;
}
return null;
},
async onSync(sync) {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
const found = messages.find(
item => item.get('source') === sync.get('source')
);
const syncSource = sync.get('source');
const syncTimestamp = sync.get('timestamp');
const wasMessageFound = Boolean(found);
window.log.info('Receive view sync:', {
syncSource,
syncTimestamp,
wasMessageFound,
});
if (!found) {
return;
}
const message = MessageController.register(found.id, found);
const viewedAt = sync.get('viewedAt');
await message.startTapToViewTimer(viewedAt, { fromSync: true });
this.remove(sync);
} catch (error) {
window.log.error(
'ViewSyncs.onSync error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View File

@ -131,6 +131,11 @@
'download',
this.downloadAttachment
);
this.listenTo(
this.model.messageCollection,
'display-tap-to-view-message',
this.displayTapToViewMessage
);
this.listenTo(
this.model.messageCollection,
'open-conversation',
@ -461,8 +466,8 @@
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightBoxView) {
this.lightBoxView.remove();
if (this.lightboxView) {
this.lightboxView.remove();
}
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
@ -1344,6 +1349,66 @@
});
},
async displayTapToViewMessage(messageId) {
const message = this.model.messageCollection.get(messageId);
if (!message) {
throw new Error(
`displayTapToViewMessage: Did not find message for id ${messageId}`
);
}
if (!message.isTapToView()) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} is not tap to view`
);
}
if (message.isTapToViewExpired()) {
return;
}
await message.startTapToViewTimer();
const closeLightbox = () => {
if (!this.lightboxView) {
return;
}
const { lightboxView } = this;
this.lightboxView = null;
this.stopListening(message);
Signal.Backbone.Views.Lightbox.hide();
lightboxView.remove();
};
this.listenTo(message, 'expired', closeLightbox);
this.listenTo(message, 'change', () => {
if (this.lightBoxView) {
this.lightBoxView.update(getProps());
}
});
const getProps = () => {
const firstAttachment = message.get('attachments')[0];
const { path, contentType } = firstAttachment;
return {
objectURL: getAbsoluteAttachmentPath(path),
contentType,
timerExpiresAt: message.get('messageTimerExpiresAt'),
timerDuration: message.get('messageTimer') * 1000,
};
};
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.Lightbox,
props: getProps(),
onClose: closeLightbox,
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
deleteMessage(messageId) {
const message = this.model.messageCollection.get(messageId);
if (!message) {

View File

@ -1110,11 +1110,19 @@ MessageReceiver.prototype.extend({
return this.handleVerified(envelope, syncMessage.verified);
} else if (syncMessage.configuration) {
return this.handleConfiguration(envelope, syncMessage.configuration);
} else if (syncMessage.stickerPackOperation) {
} else if (
syncMessage.stickerPackOperation &&
syncMessage.stickerPackOperation.length > 0
) {
return this.handleStickerPackOperation(
envelope,
syncMessage.stickerPackOperation
);
} else if (syncMessage.messageTimerRead) {
return this.handleMessageTimerRead(
envelope,
syncMessage.messageTimerRead
);
}
throw new Error('Got empty SyncMessage');
},
@ -1125,6 +1133,17 @@ MessageReceiver.prototype.extend({
ev.configuration = configuration;
return this.dispatchAndWait(ev);
},
handleMessageTimerRead(envelope, sync) {
window.log.info('got message timer read sync message');
const ev = new Event('viewSync');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.source = sync.sender;
ev.timestamp = sync.timestamp ? sync.timestamp.toNumber() : null;
ev.viewedAt = envelope.timestamp;
return this.dispatchAndWait(ev);
},
handleStickerPackOperation(envelope, operations) {
const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type;
window.log.info('got sticker pack operation sync message');

View File

@ -750,6 +750,34 @@ MessageSender.prototype = {
return Promise.resolve();
},
async syncMessageTimerRead(sender, timestamp, options) {
const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice === 1 || myDevice === '1') {
return null;
}
const syncMessage = this.createSyncMessage();
const messageTimerRead = new textsecure.protobuf.SyncMessage.MessageTimerRead();
messageTimerRead.sender = sender;
messageTimerRead.timestamp = timestamp;
syncMessage.messageTimerRead = messageTimerRead;
const contentMessage = new textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
return this.sendIndividualProto(
myNumber,
contentMessage,
Date.now(),
silent,
options
);
},
async sendStickerPackSync(operations, options) {
const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice === 1 || myDevice === '1') {
@ -1238,6 +1266,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.getSticker = sender.getSticker.bind(sender);
this.getStickerPackManifest = sender.getStickerPackManifest.bind(sender);
this.sendStickerPackSync = sender.sendStickerPackSync.bind(sender);
this.syncMessageTimerRead = sender.syncMessageTimerRead.bind(sender);
};
textsecure.MessageSender.prototype = {

View File

@ -173,7 +173,8 @@ message DataMessage {
option allow_alias = true;
INITIAL = 0;
CURRENT = 0;
MESSAGE_TIMERS = 1;
CURRENT = 1;
}
optional string body = 1;
@ -188,6 +189,7 @@ message DataMessage {
repeated Preview preview = 10;
optional Sticker sticker = 11;
optional uint32 requiredProtocolVersion = 12;
optional uint32 messageTimer = 13;
}
message NullMessage {
@ -291,6 +293,11 @@ message SyncMessage {
optional Type type = 3;
}
message MessageTimerRead {
optional string sender = 1;
optional uint64 timestamp = 2;
}
optional Sent sent = 1;
optional Contacts contacts = 2;
optional Groups groups = 3;
@ -301,6 +308,7 @@ message SyncMessage {
optional Configuration configuration = 9;
optional bytes padding = 8;
repeated StickerPackOperation stickerPackOperation = 10;
optional MessageTimerRead messageTimerRead = 11;
}
message AttachmentPointer {

View File

@ -13,6 +13,48 @@
color: $color-gray-90;
}
.module-message__container--with-tap-to-view-expired {
border: 1px solid $color-gray-15;
background-color: $color-white;
}
.module-message__container--with-tap-to-view-error {
background-color: $color-white;
border: 1px solid $color-deep-red;
}
.module-message__tap-to-view__icon {
background-color: $color-gray-90;
}
.module-message__tap-to-view__icon--outgoing {
background-color: $color-white;
}
.module-message__tap-to-view__icon--expired {
background-color: $color-gray-75;
}
.module-message__tap-to-view__text {
color: $color-gray-90;
}
.module-message__tap-to-view__text--incoming {
color: $color-gray-90;
}
.module-message__tap-to-view__text--outgoing {
color: $color-white;
}
.module-message__tap-to-view__text--outgoing-expired {
color: $color-gray-90;
}
.module-message__tap-to-view__text--incoming-expired {
color: $color-gray-90;
}
.module-message__tap-to-view__text--incoming-error {
color: $color-gray-60;
}
.module-message__container--with-tap-to-view-pending {
background-color: $color-gray-15;
}
.module-message__author {
color: $color-gray-90;
}
@ -46,19 +88,22 @@
.module-message__metadata__date--with-sticker {
color: $color-gray-60;
}
.module-message__metadata__date--outgoing-with-tap-to-view-expired {
color: $color-gray-75;
}
.module-message__metadata__status-icon--sending {
@include color-svg('../images/sending.svg', $color-white);
background-color: $color-white;
}
.module-message__metadata__status-icon--sent {
@include color-svg('../images/check-circle-outline.svg', $color-white-08);
background-color: $color-white-08;
}
.module-message__metadata__status-icon--delivered {
@include color-svg('../images/double-check.svg', $color-white-08);
background-color: $color-white-08;
}
.module-message__metadata__status-icon--read {
@include color-svg('../images/read.svg', $color-white-08);
background-color: $color-white-08;
}
.module-message__metadata__status-icon--with-image-no-caption {
@ -67,6 +112,9 @@
.module-message__metadata__status-icon--with-sticker {
background-color: $color-gray-60;
}
.module-message__metadata__status-icon--with-tap-to-view-expired {
background-color: $color-gray-75;
}
.module-message__generic-attachment__file-name {
color: $color-white;
@ -93,6 +141,9 @@
.module-expire-timer--with-sticker {
background-color: $color-gray-60;
}
.module-expire-timer--outgoing-with-tap-to-view-expired {
background-color: $color-gray-75;
}
.module-quote--outgoing {
border-left-color: $color-white;
@ -167,6 +218,16 @@
color: $color-gray-05;
}
.module-message__container--with-tap-to-view-expired {
border: 1px solid $color-gray-60;
background-color: $color-black;
}
.module-message__container--with-tap-to-view-error {
background-color: $color-black;
border: 1px solid $color-deep-red;
}
.module-message__author {
color: $color-gray-05;
}
@ -180,17 +241,17 @@
}
.module-message__metadata__status-icon--sending {
@include color-svg('../images/sending.svg', $color-white);
background-color: $color-white;
}
.module-message__metadata__status-icon--sent {
@include color-svg('../images/check-circle-outline.svg', $color-white-08);
background-color: $color-white-08;
}
.module-message__metadata__status-icon--delivered {
@include color-svg('../images/double-check.svg', $color-white-08);
background-color: $color-white-08;
}
.module-message__metadata__status-icon--read {
@include color-svg('../images/read.svg', $color-white-08);
background-color: $color-white-08;
}
.module-message__metadata__date {

View File

@ -204,6 +204,121 @@
background-color: $color-conversation-blue_grey;
}
.module-message__container--with-tap-to-view {
min-width: 148px;
cursor: pointer;
}
.module-message__container--incoming--tap-to-view-pending {
background-color: $color-conversation-grey-shade;
}
.module-message__container--incoming-red-tap-to-view-pending {
background-color: $color-conversation-red-shade;
}
.module-message__container--incoming-deep_orange-tap-to-view-pending {
background-color: $color-conversation-deep_orange-shade;
}
.module-message__container--incoming-brown-tap-to-view-pending {
background-color: $color-conversation-brown-shade;
}
.module-message__container--incoming-pink-tap-to-view-pending {
background-color: $color-conversation-pink-shade;
}
.module-message__container--incoming-purple-tap-to-view-pending {
background-color: $color-conversation-purple-shade;
}
.module-message__container--incoming-indigo-tap-to-view-pending {
background-color: $color-conversation-indigo-shade;
}
.module-message__container--incoming-blue-tap-to-view-pending {
background-color: $color-conversation-blue-shade;
}
.module-message__container--incoming-teal-tap-to-view-pending {
background-color: $color-conversation-teal-shade;
}
.module-message__container--incoming-green-tap-to-view-pending {
background-color: $color-conversation-green-shade;
}
.module-message__container--incoming-light_green-tap-to-view-pending {
background-color: $color-conversation-light_green-shade;
}
.module-message__container--incoming-blue_grey-tap-to-view-pending {
background-color: $color-conversation-blue_grey-shade;
}
.module-message__container--with-tap-to-view-pending {
cursor: default;
}
.module-message__container--with-tap-to-view-expired {
cursor: default;
border: 1px solid $color-gray-15;
background-color: $color-white;
}
.module-message__container--with-tap-to-view-error {
background-color: $color-white;
border: 1px solid $color-core-red;
width: auto;
cursor: default;
}
.module-message__tap-to-view {
margin-top: 2px;
display: flex;
flex-direction: row;
align-items: center;
}
.module-message__tap-to-view--with-content-above {
margin-top: 8px;
}
.module-message__tap-to-view--with-content-below {
margin-bottom: 8px;
}
.module-message__tap-to-view__spinner-container {
margin-right: 6px;
flex-grow: 0;
flex-shrink: 0;
width: 20px;
height: 20px;
}
.module-message__tap-to-view__icon {
margin-right: 6px;
flex-grow: 0;
flex-shrink: 0;
width: 20px;
height: 20px;
@include color-svg('../images/play-filled-24.svg', $color-white);
}
.module-message__tap-to-view__icon--outgoing {
background-color: $color-gray-75;
}
.module-message__tap-to-view__icon--expired {
@include color-svg('../images/play-outline-24.svg', $color-gray-75);
}
.module-message__tap-to-view__text {
font-size: 13px;
font-weight: 300;
line-height: 18px;
color: $color-gray-90;
}
.module-message__tap-to-view__text--incoming {
color: $color-white;
}
.module-message__tap-to-view__text--incoming-expired {
color: $color-gray-90;
}
.module-message__tap-to-view__text--incoming-error {
color: $color-gray-60;
}
.module-message__attachment-container {
// To ensure that images are centered if they aren't full width of bubble
text-align: center;
@ -472,6 +587,22 @@
}
}
.module-message__author--with-tap-to-view-expired {
color: $color-gray-75;
font-size: 13px;
font-weight: 300;
line-height: 18px;
height: 18px;
overflow-x: hidden;
overflow-y: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&__profile-name {
font-style: italic;
}
}
.module-message__author_with_sticker {
color: $color-gray-90;
font-size: 13px;
@ -555,6 +686,9 @@
.module-message__metadata__date--with-image-no-caption {
color: $color-white;
}
.module-message__metadata__date--incoming-with-tap-to-view-expired {
color: $color-gray-75;
}
.module-message__metadata__spacer {
flex-grow: 1;
@ -683,6 +817,9 @@
.module-expire-timer--incoming {
background-color: $color-white-08;
}
.module-expire-timer--incoming-with-tap-to-view-expired {
background-color: $color-gray-75;
}
// When status indicators are overlaid on top of an image, they use different colors
.module-expire-timer--with-image-no-caption {
@ -2813,8 +2950,8 @@
@include color-svg('../images/spinner-track-56.svg', $color-white-04);
z-index: 2;
height: 56px;
width: 56px;
height: 100%;
width: 100%;
}
.module-spinner__arc {
position: absolute;
@ -2823,8 +2960,8 @@
@include color-svg('../images/spinner-56.svg', $color-gray-60);
z-index: 3;
height: 56px;
width: 56px;
height: 100%;
width: 100%;
animation: spinner-arc-animation 1000ms linear infinite;
}
@ -2844,38 +2981,13 @@
// 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;
-webkit-mask-size: 100%;
height: 24px;
width: 24px;
}
.module-spinner__container--mini {
height: 14px;
width: 14px;
}
.module-spinner__circle--mini {
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
-webkit-mask-size: 100%;
height: 14px;
width: 14px;
}
.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 {
@ -4524,6 +4636,19 @@
}
}
// Module: Countdown
.module-countdown {
display: block;
width: 100%;
}
.module-countdown__path {
fill-opacity: 0;
stroke: $color-white;
stroke-width: 2;
}
// Third-party module: react-contextmenu
.react-contextmenu {

View File

@ -579,6 +579,75 @@ body.dark-theme {
background-color: $color-conversation-blue_grey;
}
.module-message__container--incoming--tap-to-view-pending {
background-color: $color-conversation-grey-shade;
}
.module-message__container--incoming-red-tap-to-view-pending {
background-color: $color-conversation-red-shade;
}
.module-message__container--incoming-deep_orange-tap-to-view-pending {
background-color: $color-conversation-deep_orange-shade;
}
.module-message__container--incoming-brown-tap-to-view-pending {
background-color: $color-conversation-brown-shade;
}
.module-message__container--incoming-pink-tap-to-view-pending {
background-color: $color-conversation-pink-shade;
}
.module-message__container--incoming-purple-tap-to-view-pending {
background-color: $color-conversation-purple-shade;
}
.module-message__container--incoming-indigo-tap-to-view-pending {
background-color: $color-conversation-indigo-shade;
}
.module-message__container--incoming-blue-tap-to-view-pending {
background-color: $color-conversation-blue-shade;
}
.module-message__container--incoming-teal-tap-to-view-pending {
background-color: $color-conversation-teal-shade;
}
.module-message__container--incoming-green-tap-to-view-pending {
background-color: $color-conversation-green-shade;
}
.module-message__container--incoming-light_green-tap-to-view-pending {
background-color: $color-conversation-light_green-shade;
}
.module-message__container--incoming-blue_grey-tap-to-view-pending {
background-color: $color-conversation-blue_grey-shade;
}
.module-message__container--with-tap-to-view-expired {
border: 1px solid $color-gray-60;
background-color: $color-black;
}
.module-message__container--with-tap-to-view-error {
background-color: $color-gray-95;
border: 1px solid $color-deep-red;
}
.module-message__tap-to-view__icon {
background-color: $color-gray-05;
}
.module-message__tap-to-view__icon--outgoing {
background-color: $color-gray-05;
}
.module-message__tap-to-view__icon--expired {
background-color: $color-gray-05;
}
.module-message__tap-to-view__text {
color: $color-gray-05;
}
.module-message__tap-to-view__text--incoming {
color: $color-gray-05;
}
.module-message__tap-to-view__text--incoming-expired {
color: $color-gray-05;
}
.module-message__tap-to-view__text--incoming-error {
color: $color-gray-25;
}
.module-message__attachment-container {
background-color: $color-gray-95;
}
@ -674,6 +743,10 @@ body.dark-theme {
color: $color-white;
}
.module-message__author--with-tap-to-view-expired {
color: $color-white;
}
.module-message__author_with_sticker {
color: $color-gray-05;
}

View File

@ -35,6 +35,7 @@ $roboto-light: Roboto-Light, 'Helvetica Neue', 'Source Sans Pro Light',
$color-signal-blue: #2090ea;
$color-core-green: #4caf50;
$color-core-red: #f44336;
$color-deep-red: #ff261f;
$color-signal-blue-025: rgba($color-signal-blue, 0.25);
$color-signal-blue-050: rgba($color-signal-blue, 0.5);

View File

@ -474,6 +474,7 @@
<script type="text/javascript" src="../js/message_controller.js" data-cover></script>
<script type="text/javascript" src="../js/keychange_listener.js" data-cover></script>
<script type='text/javascript' src='../js/expiring_messages.js' data-cover></script>
<script type='text/javascript' src='../js/expiring_tap_to_view_messages.js' data-cover></script>
<script type='text/javascript' src='../js/notifications.js' data-cover></script>
<script type='text/javascript' src='../js/focus_listener.js'></script>

View File

@ -0,0 +1,23 @@
#### New timer
```jsx
<div style={{ backgroundColor: 'darkgray' }}>
<Countdown
expiresAt={Date.now() + 10 * 1000}
duration={10 * 1000}
onComplete={() => console.log('onComplete - new timer')}
/>
</div>
```
#### Already started
```jsx
<div style={{ backgroundColor: 'darkgray' }}>
<Countdown
expiresAt={Date.now() + 10 * 1000}
duration={30 * 1000}
onComplete={() => console.log('onComplete - already started')}
/>
</div>
```

View File

@ -0,0 +1,99 @@
import React from 'react';
// import classNames from 'classnames';
interface Props {
duration: number;
expiresAt: number;
onComplete?: () => unknown;
}
interface State {
ratio: number;
}
const CIRCUMFERENCE = 11.013 * 2 * Math.PI;
export class Countdown extends React.Component<Props, State> {
public looping = false;
constructor(props: Props) {
super(props);
const { duration, expiresAt } = this.props;
const ratio = getRatio(expiresAt, duration);
this.state = { ratio };
}
public componentDidMount() {
this.startLoop();
}
public componentDidUpdate() {
this.startLoop();
}
public componentWillUnmount() {
this.stopLoop();
}
public startLoop() {
if (this.looping) {
return;
}
this.looping = true;
requestAnimationFrame(this.loop);
}
public stopLoop() {
this.looping = false;
}
public loop = () => {
const { onComplete, duration, expiresAt } = this.props;
if (!this.looping) {
return;
}
const ratio = getRatio(expiresAt, duration);
this.setState({ ratio });
if (ratio === 1) {
this.looping = false;
if (onComplete) {
onComplete();
}
} else {
requestAnimationFrame(this.loop);
}
};
public render() {
const { ratio } = this.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>
);
}
}
function getRatio(expiresAt: number, duration: number) {
const start = expiresAt - duration;
const end = expiresAt;
const now = Date.now();
const totalTime = end - start;
const elapsed = now - start;
return Math.min(Math.max(0, elapsed / totalTime), 1);
}

View File

@ -1,6 +1,6 @@
## Image
```js
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
@ -15,7 +15,7 @@ const noop = () => {};
## Image with caption
```js
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
@ -29,9 +29,27 @@ const noop = () => {};
</div>;
```
## Image with timer
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<Lightbox
objectURL="https://placekitten.com/800/600"
contentType="image/jpeg"
timerExpiresAt={Date.now() + 10 * 1000}
timerDuration={30 * 1000}
onSave={null}
close={() => console.log('close')}
i18n={util.i18n}
/>
</div>;
```
## Image (unsupported format)
```js
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
@ -46,7 +64,7 @@ const noop = () => {};
## Video (supported format)
```js
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
@ -61,7 +79,7 @@ const noop = () => {};
## Video (unsupported format)
```js
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
@ -76,7 +94,7 @@ const noop = () => {};
## Unsupported file format
```js
```jsx
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 600 }}>

View File

@ -104,6 +104,9 @@ const styles = {
saveButton: {
marginTop: 10,
},
countdownContainer: {
padding: 8,
},
iconButtonPlaceholder: {
// Dimensions match `.iconButton`:
display: 'inline-block',
@ -211,11 +214,11 @@ export class Lightbox extends React.Component<Props> {
const {
caption,
contentType,
i18n,
objectURL,
onNext,
onPrevious,
onSave,
i18n,
} = this.props;
return (

View File

@ -1,32 +1,47 @@
#### Large
#### Normal, no size
```jsx
<util.ConversationContext theme={util.theme}>
<Spinner size="normal" />
<Spinner svgSize="normal" />
<div style={{ backgroundColor: '#2090ea' }}>
<Spinner size="normal" />
<Spinner svgSize="normal" />
</div>
</util.ConversationContext>
```
#### Small
#### Normal, with size
```jsx
<util.ConversationContext theme={util.theme}>
<Spinner size="small" />
<Spinner svgSize="normal" size="100px" />
<div style={{ backgroundColor: '#2090ea' }}>
<Spinner size="small" />
<Spinner svgSize="normal" size="100px" />
</div>
</util.ConversationContext>
```
#### Mini
#### Small, no size
```jsx
<util.ConversationContext theme={util.theme}>
<Spinner size="mini" />
<Spinner svgSize="small" />
<div style={{ backgroundColor: '#2090ea' }}>
<Spinner size="mini" />
<Spinner svgSize="small" />
</div>
</util.ConversationContext>
```
#### Small, sizes
```jsx
<util.ConversationContext theme={util.theme}>
<Spinner svgSize="small" size="20px" />
<div style={{ backgroundColor: '#2090ea' }}>
<Spinner svgSize="small" size="20px" />
</div>
<Spinner svgSize="small" size="14px" />
<div style={{ backgroundColor: '#2090ea' }}>
<Spinner svgSize="small" size="14px" />
</div>
</util.ConversationContext>
```

View File

@ -2,37 +2,44 @@ import React from 'react';
import classNames from 'classnames';
interface Props {
size: 'small' | 'mini' | 'normal';
size?: string;
svgSize: 'small' | 'normal';
direction?: string;
}
export class Spinner extends React.Component<Props> {
public render() {
const { size, direction } = this.props;
const { size, svgSize, direction } = this.props;
return (
<div
className={classNames(
'module-spinner__container',
`module-spinner__container--${size}`,
`module-spinner__container--${svgSize}`,
direction ? `module-spinner__container--${direction}` : null,
direction ? `module-spinner__container--${size}-${direction}` : null
direction
? `module-spinner__container--${svgSize}-${direction}`
: null
)}
style={{
height: size,
width: size,
}}
>
<div
className={classNames(
'module-spinner__circle',
`module-spinner__circle--${size}`,
`module-spinner__circle--${svgSize}`,
direction ? `module-spinner__circle--${direction}` : null,
direction ? `module-spinner__circle--${size}-${direction}` : null
direction ? `module-spinner__circle--${svgSize}-${direction}` : null
)}
/>
<div
className={classNames(
'module-spinner__arc',
`module-spinner__arc--${size}`,
`module-spinner__arc--${svgSize}`,
direction ? `module-spinner__arc--${direction}` : null,
direction ? `module-spinner__arc--${size}-${direction}` : null
direction ? `module-spinner__arc--${svgSize}-${direction}` : null
)}
/>
</div>

View File

@ -199,6 +199,42 @@ const contact = {
/>;
```
### With all data types
```jsx
const contact = {
avatar: {
avatar: {
pending: true,
},
},
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 3,
},
],
address: [
{
street: '5 Pike Place',
city: 'Seattle',
region: 'WA',
postcode: '98101',
type: 1,
},
],
};
<ContactDetail
contact={contact}
hasSignalAccount={true}
i18n={util.i18n}
onSendMessage={() => console.log('onSendMessage')}
/>;
```
### Empty contact
```jsx

View File

@ -4,8 +4,9 @@ import classNames from 'classnames';
import { getIncrement, getTimerBucket } from '../../util/timer';
interface Props {
withImageNoCaption: boolean;
withSticker: boolean;
withImageNoCaption?: boolean;
withSticker?: boolean;
withTapToViewExpired?: boolean;
expirationLength: number;
expirationTimestamp: number;
direction?: 'incoming' | 'outgoing';
@ -46,6 +47,7 @@ export class ExpireTimer extends React.Component<Props> {
expirationTimestamp,
withImageNoCaption,
withSticker,
withTapToViewExpired,
} = this.props;
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
@ -55,8 +57,11 @@ export class ExpireTimer extends React.Component<Props> {
className={classNames(
'module-expire-timer',
`module-expire-timer--${bucket}`,
`module-expire-timer--${direction}`,
withImageNoCaption
direction ? `module-expire-timer--${direction}` : null,
withTapToViewExpired
? `module-expire-timer--${direction}-with-tap-to-view-expired`
: null,
direction && withImageNoCaption
? 'module-expire-timer--with-image-no-caption'
: null,
withSticker ? 'module-expire-timer--with-sticker' : null

View File

@ -99,7 +99,7 @@ export class Image extends React.Component<Props> {
}}
// alt={i18n('loading')}
>
<Spinner size="normal" />
<Spinner svgSize="normal" />
</div>
) : (
<img

View File

@ -3565,6 +3565,440 @@ Sticker link previews are forced to use the small link preview form, no matter t
</util.ConversationContext>
```
### Tap to view
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
authorColor="pink"
conversationType="direct"
authorPhoneNumber="(202) 555-0003"
isTapToViewExpired={false}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId1"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
authorColor="blue"
isTapToViewExpired={true}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
conversationType="direct"
i18n={util.i18n}
id="messageId2"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
authorColor="green"
conversationType="group"
authorPhoneNumber="(202) 555-0003"
isTapToViewExpired={false}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId3"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
conversationType="group"
authorPhoneNumber="(202) 555-0003"
authorColor="blue"
isTapToViewExpired={true}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
conversationType="group"
i18n={util.i18n}
id="messageId4"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
conversationType="group"
authorPhoneNumber="(202) 555-0003"
authorProfileName="A very long profile name which cannot be shown in its entirety, or maybe it can!"
authorColor="blue"
isTapToViewExpired={true}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
conversationType="group"
i18n={util.i18n}
id="messageId4"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
collapseMetadata={true}
authorColor="blue"
isTapToViewExpired={true}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
conversationType="direct"
i18n={util.i18n}
id="messageId5"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
/>
</li>
<li>
<Message
direction="outgoing"
authorColor="red"
status="delivered"
timestamp={Date.now()}
conversationType="group"
authorName="Not shown"
isTapToViewExpired={false}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="outgoing"
authorColor="green"
status="read"
collapseMetadata={true}
timestamp={Date.now()}
isTapToViewExpired={false}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId8"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="outgoing"
authorColor="red"
status="delivered"
timestamp={Date.now()}
conversationType="group"
authorName="Not shown"
isTapToViewExpired={true}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="outgoing"
authorColor="green"
status="read"
collapseMetadata={true}
timestamp={Date.now()}
isTapToViewExpired={true}
isTapToView={true}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId8"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
authorColor="green"
isTapToViewExpired={false}
isTapToView={true}
expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId3"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
timestamp={Date.now()}
authorColor="blue"
isTapToViewExpired={true}
isTapToView={true}
expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId4"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
/>
</li>
<li>
<Message
direction="outgoing"
authorColor="red"
status="delivered"
timestamp={Date.now()}
isTapToViewExpired={false}
isTapToView={true}
expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="outgoing"
authorColor="red"
status="delivered"
timestamp={Date.now()}
isTapToViewExpired={true}
isTapToView={true}
expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000}
text="This should not be shown"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
status="delivered"
timestamp={Date.now()}
isTapToViewExpired={false}
isTapToView={true}
text="This should not be shown"
attachments={[
{
pending: true,
contentType: 'image/gif',
},
]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
status="delivered"
timestamp={Date.now()}
isTapToViewExpired={true}
isTapToView={true}
isTapToViewError={true}
text="This should not be shown"
attachments={[]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
<li>
<Message
direction="incoming"
authorColor="red"
status="delivered"
conversationType="group"
timestamp={Date.now()}
isTapToViewExpired={true}
isTapToView={true}
isTapToViewError={true}
text="This should not be shown"
attachments={[]}
i18n={util.i18n}
id="messageId6"
displayTapToViewMessage={(...args) =>
console.log('displayTapToViewMessage', args)
}
authorAvatarPath={util.gifObjectUrl}
/>
</li>
</util.ConversationContext>
```
### In a group conversation
Note that the author avatar goes away if `collapseMetadata` is set.

View File

@ -80,6 +80,11 @@ export type PropsData = {
previews: Array<LinkPreviewType>;
authorAvatarPath?: string;
isExpired: boolean;
isTapToView?: boolean;
isTapToViewExpired?: boolean;
isTapToViewError?: boolean;
expirationLength?: number;
expirationTimestamp?: number;
};
@ -112,6 +117,7 @@ export type PropsActions = {
isDangerous: boolean;
}
) => void;
displayTapToViewMessage: (messageId: string) => unknown;
openLink: (url: string) => void;
scrollToMessage: (
@ -227,6 +233,7 @@ export class Message extends React.PureComponent<Props, State> {
expirationTimestamp,
i18n,
isSticker,
isTapToViewExpired,
status,
text,
textPending,
@ -274,6 +281,7 @@ export class Message extends React.PureComponent<Props, State> {
direction={metadataDirection}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
module="module-message__metadata__date"
/>
)}
@ -284,12 +292,13 @@ export class Message extends React.PureComponent<Props, State> {
expirationTimestamp={expirationTimestamp}
withImageNoCaption={withImageNoCaption}
withSticker={isSticker}
withTapToViewExpired={isTapToViewExpired}
/>
) : null}
<span className="module-message__metadata__spacer" />
{textPending ? (
<div className="module-message__metadata__spinner-container">
<Spinner size="mini" direction={direction} />
<Spinner svgSize="small" size="14px" direction={direction} />
</div>
) : null}
{!textPending && direction === 'outgoing' && status !== 'error' ? (
@ -302,6 +311,9 @@ export class Message extends React.PureComponent<Props, State> {
: null,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null,
isTapToViewExpired
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
: null
)}
/>
@ -320,6 +332,8 @@ export class Message extends React.PureComponent<Props, State> {
direction,
i18n,
isSticker,
isTapToView,
isTapToViewExpired,
} = this.props;
if (collapseMetadata) {
@ -332,8 +346,13 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
const suffix = isSticker ? '_with_sticker' : '';
const moduleName = `module-message__author${suffix}`;
const withTapToViewExpired = isTapToView && isTapToViewExpired;
const stickerSuffix = isSticker ? '_with_sticker' : '';
const tapToViewSuffix = withTapToViewExpired
? '--with-tap-to-view-expired'
: '';
const moduleName = `module-message__author${stickerSuffix}${tapToViewSuffix}`;
return (
<div className={moduleName}>
@ -452,7 +471,7 @@ export class Message extends React.PureComponent<Props, State> {
>
{pending ? (
<div className="module-message__generic-attachment__spinner-container">
<Spinner size="small" direction={direction} />
<Spinner svgSize="small" size="24px" direction={direction} />
</div>
) : (
<div className="module-message__generic-attachment__icon-container">
@ -805,6 +824,7 @@ export class Message extends React.PureComponent<Props, State> {
downloadAttachment,
id,
isSticker,
isTapToView,
replyToMessage,
timestamp,
} = this.props;
@ -822,6 +842,7 @@ export class Message extends React.PureComponent<Props, State> {
const downloadButton =
!isSticker &&
!multipleAttachments &&
!isTapToView &&
firstAttachment &&
!firstAttachment.pending ? (
<div
@ -886,15 +907,16 @@ export class Message extends React.PureComponent<Props, State> {
public renderContextMenu(triggerId: string) {
const {
attachments,
deleteMessage,
direction,
downloadAttachment,
i18n,
id,
isSticker,
deleteMessage,
showMessageDetail,
isTapToView,
replyToMessage,
retrySend,
showMessageDetail,
status,
timestamp,
} = this.props;
@ -907,7 +929,11 @@ export class Message extends React.PureComponent<Props, State> {
const menu = (
<ContextMenu id={triggerId}>
{!isSticker && !multipleAttachments && attachments && attachments[0] ? (
{!isSticker &&
!multipleAttachments &&
!isTapToView &&
attachments &&
attachments[0] ? (
<MenuItem
attributes={{
className: 'module-message__context__download',
@ -1011,10 +1037,10 @@ export class Message extends React.PureComponent<Props, State> {
}
public isShowingImage() {
const { attachments, previews } = this.props;
const { isTapToView, attachments, previews } = this.props;
const { imageBroken } = this.state;
if (imageBroken) {
if (imageBroken || isTapToView) {
return false;
}
@ -1042,17 +1068,153 @@ export class Message extends React.PureComponent<Props, State> {
return false;
}
public isAttachmentPending() {
const { attachments } = this.props;
if (!attachments || attachments.length < 1) {
return false;
}
const first = attachments[0];
return Boolean(first.pending);
}
public renderTapToViewIcon() {
const { direction, isTapToViewExpired } = this.props;
const isDownloadPending = this.isAttachmentPending();
return !isTapToViewExpired && isDownloadPending ? (
<div className="module-message__tap-to-view__spinner-container">
<Spinner svgSize="small" size="20px" direction={direction} />
</div>
) : (
<div
className={classNames(
'module-message__tap-to-view__icon',
`module-message__tap-to-view__icon--${direction}`,
isTapToViewExpired
? 'module-message__tap-to-view__icon--expired'
: null
)}
/>
);
}
public renderTapToViewText() {
const {
direction,
i18n,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const incomingString = isTapToViewExpired
? i18n('Message--tap-to-view-expired')
: i18n('Message--tap-to-view--incoming');
const outgoingString = i18n('Message--tap-to-view--outgoing');
const isDownloadPending = this.isAttachmentPending();
if (isDownloadPending) {
return;
}
return isTapToViewError
? i18n('incomingError')
: direction === 'outgoing'
? outgoingString
: incomingString;
}
public renderTapToView() {
const {
collapseMetadata,
conversationType,
direction,
isTapToViewExpired,
isTapToViewError,
} = this.props;
const withContentBelow = !collapseMetadata;
const withContentAbove =
!collapseMetadata &&
conversationType === 'group' &&
direction === 'incoming';
return (
<div
className={classNames(
'module-message__tap-to-view',
withContentBelow
? 'module-message__tap-to-view--with-content-below'
: null,
withContentAbove
? 'module-message__tap-to-view--with-content-above'
: null
)}
>
{isTapToViewError ? null : this.renderTapToViewIcon()}
<div
className={classNames(
'module-message__tap-to-view__text',
`module-message__tap-to-view__text--${direction}`,
isTapToViewExpired
? `module-message__tap-to-view__text--${direction}-expired`
: null,
isTapToViewError
? `module-message__tap-to-view__text--${direction}-error`
: null
)}
>
{this.renderTapToViewText()}
</div>
</div>
);
}
public renderContents() {
const { isTapToView } = this.props;
if (isTapToView) {
return (
<>
{this.renderTapToView()}
{this.renderMetadata()}
</>
);
}
return (
<>
{this.renderQuote()}
{this.renderAttachment()}
{this.renderPreview()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
</>
);
}
// tslint:disable-next-line cyclomatic-complexity
public render() {
const {
authorPhoneNumber,
authorColor,
attachments,
direction,
displayTapToViewMessage,
id,
isSticker,
isTapToView,
isTapToViewExpired,
isTapToViewError,
timestamp,
} = this.props;
const { expired, expiring, imageBroken } = this.state;
const isAttachmentPending = this.isAttachmentPending();
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
@ -1068,6 +1230,8 @@ export class Message extends React.PureComponent<Props, State> {
const width = this.getWidth();
const isShowingImage = this.isShowingImage();
const role = isButton ? 'button' : undefined;
const onClick = isButton ? () => displayTapToViewMessage(id) : undefined;
return (
<div
@ -1084,22 +1248,31 @@ export class Message extends React.PureComponent<Props, State> {
'module-message__container',
isSticker ? 'module-message__container--with-sticker' : null,
!isSticker ? `module-message__container--${direction}` : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null,
isTapToView && isTapToViewExpired
? 'module-message__container--with-tap-to-view-expired'
: null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${authorColor}`
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
: null
)}
style={{
width: isShowingImage ? width : undefined,
}}
role={role}
onClick={onClick}
>
{this.renderAuthor()}
{this.renderQuote()}
{this.renderAttachment()}
{this.renderPreview()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
{this.renderContents()}
{this.renderAvatar()}
</div>
{this.renderError(direction === 'outgoing')}

View File

@ -12,6 +12,7 @@ interface Props {
module?: string;
withImageNoCaption?: boolean;
withSticker?: boolean;
withTapToViewExpired?: boolean;
direction?: 'incoming' | 'outgoing';
i18n: LocalizerType;
}
@ -50,6 +51,7 @@ export class Timestamp extends React.Component<Props> {
timestamp,
withImageNoCaption,
withSticker,
withTapToViewExpired,
extended,
} = this.props;
const moduleName = module || 'module-timestamp';
@ -63,6 +65,9 @@ export class Timestamp extends React.Component<Props> {
className={classNames(
moduleName,
direction ? `${moduleName}--${direction}` : null,
withTapToViewExpired && direction
? `${moduleName}--${direction}-with-tap-to-view-expired`
: null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
withSticker ? `${moduleName}--with-sticker` : null
)}

View File

@ -25,12 +25,17 @@ export function renderAvatar({
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
const pending = avatar && avatar.avatar && avatar.avatar.pending;
const name = getName(contact) || '';
const spinnerSize = size < 50 ? 'small' : 'normal';
const spinnerSvgSize = size < 50 ? 'small' : 'normal';
const spinnerSize = size < 50 ? '24px' : undefined;
if (pending) {
return (
<div className="module-embedded-contact__spinner-container">
<Spinner size={spinnerSize} direction={direction} />
<Spinner
svgSize={spinnerSvgSize}
size={spinnerSize}
direction={direction}
/>
</div>
);
}

View File

@ -39,7 +39,7 @@ function renderBody({ pack, i18n }: Props) {
}
if (!pack || pack.stickerCount === 0 || !isNumber(pack.stickerCount)) {
return <Spinner size="normal" />;
return <Spinner svgSize="normal" />;
}
return (
@ -209,7 +209,7 @@ export const StickerPreviewModal = React.memo(
</div>
<div className="module-sticker-manager__preview-modal__container__meta-overlay__install">
{pack.status === 'pending' ? (
<Spinner size="mini" />
<Spinner svgSize="small" size="14px" />
) : (
<StickerPackInstallButton
ref={focusRef}

View File

@ -18,6 +18,7 @@ export type IncomingMessage = Readonly<
decrypted_at?: number;
errors?: Array<any>;
expireTimer?: number;
messageTimer?: number;
flags?: number;
source?: string;
sourceDevice?: number;
@ -46,6 +47,7 @@ export type OutgoingMessage = Readonly<
body?: string;
expires_at?: number;
expireTimer?: number;
messageTimer?: number;
recipients?: Array<string>; // Array<PhoneNumber>
synced: boolean;
} & SharedMessageProperties &

View File

@ -16,6 +16,9 @@ export const initializeAttachmentMetadata = async (
if (message.type === 'verified-change') {
return message;
}
if (message.messageTimer) {
return message;
}
const attachments = message.attachments.filter(
(attachment: Attachment.Attachment) =>

View File

@ -6095,7 +6095,7 @@
"rule": "React-createRef",
"path": "ts/components/Lightbox.js",
"line": " this.videoRef = react_1.default.createRef();",
"lineNumber": 180,
"lineNumber": 183,
"reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used to auto-start playback on videos"
@ -6104,7 +6104,7 @@
"rule": "React-createRef",
"path": "ts/components/Lightbox.tsx",
"line": " this.videoRef = React.createRef();",
"lineNumber": 176,
"lineNumber": 179,
"reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used to auto-start playback on videos"