Receive support for View Once photos
This commit is contained in:
parent
fccf1eec30
commit
e62a1a7812
38 changed files with 1937 additions and 102 deletions
|
@ -1745,6 +1745,11 @@
|
||||||
"description":
|
"description":
|
||||||
"Shown in notifications and in the left pane when a message has features too new for this signal install."
|
"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": {
|
"stickers--toast--InstallFailed": {
|
||||||
"message": "Sticker pack could not be installed",
|
"message": "Sticker pack could not be installed",
|
||||||
"description":
|
"description":
|
||||||
|
@ -1901,5 +1906,20 @@
|
||||||
"message": "Update Signal",
|
"message": "Update Signal",
|
||||||
"description":
|
"description":
|
||||||
"Text for a button which will take user to Signal download page"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
171
app/sql.js
171
app/sql.js
|
@ -94,6 +94,9 @@ module.exports = {
|
||||||
getOutgoingWithoutExpiresAt,
|
getOutgoingWithoutExpiresAt,
|
||||||
getNextExpiringMessage,
|
getNextExpiringMessage,
|
||||||
getMessagesByConversation,
|
getMessagesByConversation,
|
||||||
|
getNextTapToViewMessageToExpire,
|
||||||
|
getNextTapToViewMessageToAgeOut,
|
||||||
|
getTapToViewMessagesNeedingErase,
|
||||||
|
|
||||||
getUnprocessedCount,
|
getUnprocessedCount,
|
||||||
getAllUnprocessed,
|
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 = [
|
const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion1,
|
updateToSchemaVersion1,
|
||||||
updateToSchemaVersion2,
|
updateToSchemaVersion2,
|
||||||
|
@ -884,6 +968,7 @@ const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion13,
|
updateToSchemaVersion13,
|
||||||
updateToSchemaVersion14,
|
updateToSchemaVersion14,
|
||||||
updateToSchemaVersion15,
|
updateToSchemaVersion15,
|
||||||
|
updateToSchemaVersion16,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function updateSchema(instance) {
|
async function updateSchema(instance) {
|
||||||
|
@ -1480,6 +1565,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
||||||
hasFileAttachments,
|
hasFileAttachments,
|
||||||
hasVisualMediaAttachments,
|
hasVisualMediaAttachments,
|
||||||
id,
|
id,
|
||||||
|
isErased,
|
||||||
|
messageTimer,
|
||||||
|
messageTimerStart,
|
||||||
|
messageTimerExpiresAt,
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
received_at,
|
received_at,
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
|
@ -1505,6 +1594,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
||||||
$hasAttachments: hasAttachments,
|
$hasAttachments: hasAttachments,
|
||||||
$hasFileAttachments: hasFileAttachments,
|
$hasFileAttachments: hasFileAttachments,
|
||||||
$hasVisualMediaAttachments: hasVisualMediaAttachments,
|
$hasVisualMediaAttachments: hasVisualMediaAttachments,
|
||||||
|
$isErased: isErased,
|
||||||
|
$messageTimer: messageTimer,
|
||||||
|
$messageTimerStart: messageTimerStart,
|
||||||
|
$messageTimerExpiresAt: messageTimerExpiresAt,
|
||||||
$received_at: received_at,
|
$received_at: received_at,
|
||||||
$schemaVersion: schemaVersion,
|
$schemaVersion: schemaVersion,
|
||||||
$sent_at: sent_at,
|
$sent_at: sent_at,
|
||||||
|
@ -1517,7 +1610,9 @@ async function saveMessage(data, { forceSave } = {}) {
|
||||||
if (id && !forceSave) {
|
if (id && !forceSave) {
|
||||||
await db.run(
|
await db.run(
|
||||||
`UPDATE messages SET
|
`UPDATE messages SET
|
||||||
|
id = $id,
|
||||||
json = $json,
|
json = $json,
|
||||||
|
|
||||||
body = $body,
|
body = $body,
|
||||||
conversationId = $conversationId,
|
conversationId = $conversationId,
|
||||||
expirationStartTimestamp = $expirationStartTimestamp,
|
expirationStartTimestamp = $expirationStartTimestamp,
|
||||||
|
@ -1526,7 +1621,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
||||||
hasAttachments = $hasAttachments,
|
hasAttachments = $hasAttachments,
|
||||||
hasFileAttachments = $hasFileAttachments,
|
hasFileAttachments = $hasFileAttachments,
|
||||||
hasVisualMediaAttachments = $hasVisualMediaAttachments,
|
hasVisualMediaAttachments = $hasVisualMediaAttachments,
|
||||||
id = $id,
|
isErased = $isErased,
|
||||||
|
messageTimer = $messageTimer,
|
||||||
|
messageTimerStart = $messageTimerStart,
|
||||||
|
messageTimerExpiresAt = $messageTimerExpiresAt,
|
||||||
received_at = $received_at,
|
received_at = $received_at,
|
||||||
schemaVersion = $schemaVersion,
|
schemaVersion = $schemaVersion,
|
||||||
sent_at = $sent_at,
|
sent_at = $sent_at,
|
||||||
|
@ -1559,6 +1657,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
||||||
hasAttachments,
|
hasAttachments,
|
||||||
hasFileAttachments,
|
hasFileAttachments,
|
||||||
hasVisualMediaAttachments,
|
hasVisualMediaAttachments,
|
||||||
|
isErased,
|
||||||
|
messageTimer,
|
||||||
|
messageTimerStart,
|
||||||
|
messageTimerExpiresAt,
|
||||||
received_at,
|
received_at,
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
sent_at,
|
sent_at,
|
||||||
|
@ -1578,6 +1680,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
||||||
$hasAttachments,
|
$hasAttachments,
|
||||||
$hasFileAttachments,
|
$hasFileAttachments,
|
||||||
$hasVisualMediaAttachments,
|
$hasVisualMediaAttachments,
|
||||||
|
$isErased,
|
||||||
|
$messageTimer,
|
||||||
|
$messageTimerStart,
|
||||||
|
$messageTimerExpiresAt,
|
||||||
$received_at,
|
$received_at,
|
||||||
$schemaVersion,
|
$schemaVersion,
|
||||||
$sent_at,
|
$sent_at,
|
||||||
|
@ -1756,6 +1862,69 @@ async function getNextExpiringMessage() {
|
||||||
return map(rows, row => jsonToObject(row.json));
|
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 } = {}) {
|
async function saveUnprocessed(data, { forceSave } = {}) {
|
||||||
const { id, timestamp, version, attempts, envelope } = data;
|
const { id, timestamp, version, attempts, envelope } = data;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
|
@ -482,11 +482,13 @@
|
||||||
<script type='text/javascript' src='js/delivery_receipts.js'></script>
|
<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_receipts.js'></script>
|
||||||
<script type='text/javascript' src='js/read_syncs.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/libphonenumber-util.js'></script>
|
||||||
<script type='text/javascript' src='js/models/messages.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/conversations.js'></script>
|
||||||
<script type='text/javascript' src='js/models/blockedNumbers.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_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/chromium.js'></script>
|
||||||
<script type='text/javascript' src='js/registration.js'></script>
|
<script type='text/javascript' src='js/registration.js'></script>
|
||||||
|
|
7
images/play-filled-24.svg
Normal file
7
images/play-filled-24.svg
Normal 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 |
7
images/play-outline-24.svg
Normal file
7
images/play-outline-24.svg
Normal 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 |
|
@ -652,6 +652,7 @@
|
||||||
|
|
||||||
Whisper.WallClockListener.init(Whisper.events);
|
Whisper.WallClockListener.init(Whisper.events);
|
||||||
Whisper.ExpiringMessagesListener.init(Whisper.events);
|
Whisper.ExpiringMessagesListener.init(Whisper.events);
|
||||||
|
Whisper.TapToViewMessagesListener.init(Whisper.events);
|
||||||
|
|
||||||
if (Whisper.Import.isIncomplete()) {
|
if (Whisper.Import.isIncomplete()) {
|
||||||
window.log.info('Import was interrupted, showing import error screen');
|
window.log.info('Import was interrupted, showing import error screen');
|
||||||
|
@ -836,6 +837,7 @@
|
||||||
addQueuedEventListener('configuration', onConfiguration);
|
addQueuedEventListener('configuration', onConfiguration);
|
||||||
addQueuedEventListener('typing', onTyping);
|
addQueuedEventListener('typing', onTyping);
|
||||||
addQueuedEventListener('sticker-pack', onStickerPack);
|
addQueuedEventListener('sticker-pack', onStickerPack);
|
||||||
|
addQueuedEventListener('viewSync', onViewSync);
|
||||||
|
|
||||||
window.Signal.AttachmentDownloads.start({
|
window.Signal.AttachmentDownloads.start({
|
||||||
getMessageReceiver: () => messageReceiver,
|
getMessageReceiver: () => messageReceiver,
|
||||||
|
@ -1685,6 +1687,22 @@
|
||||||
throw error;
|
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) {
|
function onReadReceipt(ev) {
|
||||||
const readAt = ev.timestamp;
|
const readAt = ev.timestamp;
|
||||||
const { timestamp } = ev.read;
|
const { timestamp } = ev.read;
|
||||||
|
|
109
js/expiring_tap_to_view_messages.js
Normal file
109
js/expiring_tap_to_view_messages.js
Normal 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,
|
||||||
|
};
|
||||||
|
})();
|
|
@ -857,11 +857,9 @@
|
||||||
author: contact.id,
|
author: contact.id,
|
||||||
id: quotedMessage.get('sent_at'),
|
id: quotedMessage.get('sent_at'),
|
||||||
text: body || embeddedContactName,
|
text: body || embeddedContactName,
|
||||||
attachments: await this.getQuoteAttachment(
|
attachments: quotedMessage.isTapToView()
|
||||||
attachments,
|
? [{ contentType: 'image/jpeg', fileName: null }]
|
||||||
preview,
|
: await this.getQuoteAttachment(attachments, preview, sticker),
|
||||||
sticker
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -470,6 +470,8 @@
|
||||||
const isGroup = conversation && !conversation.isPrivate();
|
const isGroup = conversation && !conversation.isPrivate();
|
||||||
const sticker = this.get('sticker');
|
const sticker = this.get('sticker');
|
||||||
|
|
||||||
|
const isTapToView = this.isTapToView();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: this.createNonBreakingLastSeparator(this.get('body')),
|
text: this.createNonBreakingLastSeparator(this.get('body')),
|
||||||
textPending: this.get('bodyPending'),
|
textPending: this.get('bodyPending'),
|
||||||
|
@ -492,6 +494,12 @@
|
||||||
expirationLength,
|
expirationLength,
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
|
|
||||||
|
isTapToView,
|
||||||
|
isTapToViewExpired:
|
||||||
|
isTapToView && (this.get('isErased') || this.isTapToViewExpired()),
|
||||||
|
isTapToViewError:
|
||||||
|
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
|
||||||
|
|
||||||
replyToMessage: id => this.trigger('reply', id),
|
replyToMessage: id => this.trigger('reply', id),
|
||||||
retrySend: id => this.trigger('retry', id),
|
retrySend: id => this.trigger('retry', id),
|
||||||
deleteMessage: id => this.trigger('delete', id),
|
deleteMessage: id => this.trigger('delete', id),
|
||||||
|
@ -506,6 +514,8 @@
|
||||||
this.trigger('show-lightbox', lightboxOptions),
|
this.trigger('show-lightbox', lightboxOptions),
|
||||||
downloadAttachment: downloadOptions =>
|
downloadAttachment: downloadOptions =>
|
||||||
this.trigger('download', downloadOptions),
|
this.trigger('download', downloadOptions),
|
||||||
|
displayTapToViewMessage: messageId =>
|
||||||
|
this.trigger('display-tap-to-view-message', messageId),
|
||||||
|
|
||||||
openLink: url => this.trigger('navigate-to', url),
|
openLink: url => this.trigger('navigate-to', url),
|
||||||
downloadNewVersion: () => this.trigger('download-new-version'),
|
downloadNewVersion: () => this.trigger('download-new-version'),
|
||||||
|
@ -727,6 +737,9 @@
|
||||||
if (this.isUnsupportedMessage()) {
|
if (this.isUnsupportedMessage()) {
|
||||||
return i18n('message--getDescription--unsupported-message');
|
return i18n('message--getDescription--unsupported-message');
|
||||||
}
|
}
|
||||||
|
if (this.isTapToView()) {
|
||||||
|
return i18n('message--getDescription--disappearing-photo');
|
||||||
|
}
|
||||||
if (this.isGroupUpdate()) {
|
if (this.isGroupUpdate()) {
|
||||||
const groupUpdate = this.get('group_update');
|
const groupUpdate = this.get('group_update');
|
||||||
if (groupUpdate.left === 'You') {
|
if (groupUpdate.left === 'You') {
|
||||||
|
@ -841,6 +854,9 @@
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
MessageController.unregister(this.id);
|
MessageController.unregister(this.id);
|
||||||
this.unload();
|
this.unload();
|
||||||
|
await this.deleteData();
|
||||||
|
},
|
||||||
|
async deleteData() {
|
||||||
await deleteExternalMessageFiles(this.attributes);
|
await deleteExternalMessageFiles(this.attributes);
|
||||||
|
|
||||||
const sticker = this.get('sticker');
|
const sticker = this.get('sticker');
|
||||||
|
@ -853,6 +869,154 @@
|
||||||
await deletePackReference(this.id, packId);
|
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() {
|
unload() {
|
||||||
if (this.quotedMessage) {
|
if (this.quotedMessage) {
|
||||||
this.quotedMessage = null;
|
this.quotedMessage = null;
|
||||||
|
@ -1581,6 +1745,16 @@
|
||||||
quote.referencedMessageNotFound = true;
|
quote.referencedMessageNotFound = true;
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
if (found.isTapToView()) {
|
||||||
|
quote.text = null;
|
||||||
|
quote.attachments = [
|
||||||
|
{
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
const queryMessage = MessageController.register(found.id, found);
|
const queryMessage = MessageController.register(found.id, found);
|
||||||
quote.text = queryMessage.get('body');
|
quote.text = queryMessage.get('body');
|
||||||
|
@ -1765,6 +1939,7 @@
|
||||||
hasAttachments: dataMessage.hasAttachments,
|
hasAttachments: dataMessage.hasAttachments,
|
||||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||||
|
messageTimer: dataMessage.messageTimer,
|
||||||
preview,
|
preview,
|
||||||
requiredProtocolVersion:
|
requiredProtocolVersion:
|
||||||
dataMessage.requiredProtocolVersion ||
|
dataMessage.requiredProtocolVersion ||
|
||||||
|
@ -1925,7 +2100,34 @@
|
||||||
message.set({ id });
|
message.set({ id });
|
||||||
MessageController.register(message.id, message);
|
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
|
// 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
|
// call it after we have an id for this message, because the jobs refer back
|
||||||
// to their source message.
|
// to their source message.
|
||||||
|
@ -2017,8 +2219,10 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
Whisper.Message.refreshExpirationTimer = () =>
|
Whisper.Message.updateTimers = () => {
|
||||||
Whisper.ExpiringMessagesListener.update();
|
Whisper.ExpiringMessagesListener.update();
|
||||||
|
Whisper.TapToViewMessagesListener.update();
|
||||||
|
};
|
||||||
|
|
||||||
Whisper.MessageCollection = Backbone.Collection.extend({
|
Whisper.MessageCollection = Backbone.Collection.extend({
|
||||||
model: Whisper.Message,
|
model: Whisper.Message,
|
||||||
|
|
|
@ -715,7 +715,7 @@ async function exportConversation(conversation, options = {}) {
|
||||||
count += 1;
|
count += 1;
|
||||||
|
|
||||||
// skip message if it is disappearing, no matter the amount of time left
|
// 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
|
// eslint-disable-next-line no-continue
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,6 +122,9 @@ module.exports = {
|
||||||
getOutgoingWithoutExpiresAt,
|
getOutgoingWithoutExpiresAt,
|
||||||
getNextExpiringMessage,
|
getNextExpiringMessage,
|
||||||
getMessagesByConversation,
|
getMessagesByConversation,
|
||||||
|
getNextTapToViewMessageToExpire,
|
||||||
|
getNextTapToViewMessageToAgeOut,
|
||||||
|
getTapToViewMessagesNeedingErase,
|
||||||
|
|
||||||
getUnprocessedCount,
|
getUnprocessedCount,
|
||||||
getAllUnprocessed,
|
getAllUnprocessed,
|
||||||
|
@ -674,7 +677,7 @@ async function getMessageCount() {
|
||||||
|
|
||||||
async function saveMessage(data, { forceSave, Message } = {}) {
|
async function saveMessage(data, { forceSave, Message } = {}) {
|
||||||
const id = await channels.saveMessage(_cleanData(data), { forceSave });
|
const id = await channels.saveMessage(_cleanData(data), { forceSave });
|
||||||
Message.refreshExpirationTimer();
|
Message.updateTimers();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -839,6 +842,27 @@ async function getNextExpiringMessage({ MessageCollection }) {
|
||||||
return new MessageCollection(messages);
|
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
|
// Unprocessed
|
||||||
|
|
||||||
async function getUnprocessedCount() {
|
async function getUnprocessedCount() {
|
||||||
|
|
67
js/view_syncs.js
Normal file
67
js/view_syncs.js
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))();
|
||||||
|
})();
|
|
@ -131,6 +131,11 @@
|
||||||
'download',
|
'download',
|
||||||
this.downloadAttachment
|
this.downloadAttachment
|
||||||
);
|
);
|
||||||
|
this.listenTo(
|
||||||
|
this.model.messageCollection,
|
||||||
|
'display-tap-to-view-message',
|
||||||
|
this.displayTapToViewMessage
|
||||||
|
);
|
||||||
this.listenTo(
|
this.listenTo(
|
||||||
this.model.messageCollection,
|
this.model.messageCollection,
|
||||||
'open-conversation',
|
'open-conversation',
|
||||||
|
@ -461,8 +466,8 @@
|
||||||
if (this.quoteView) {
|
if (this.quoteView) {
|
||||||
this.quoteView.remove();
|
this.quoteView.remove();
|
||||||
}
|
}
|
||||||
if (this.lightBoxView) {
|
if (this.lightboxView) {
|
||||||
this.lightBoxView.remove();
|
this.lightboxView.remove();
|
||||||
}
|
}
|
||||||
if (this.lightboxGalleryView) {
|
if (this.lightboxGalleryView) {
|
||||||
this.lightboxGalleryView.remove();
|
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) {
|
deleteMessage(messageId) {
|
||||||
const message = this.model.messageCollection.get(messageId);
|
const message = this.model.messageCollection.get(messageId);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
|
|
|
@ -1110,11 +1110,19 @@ MessageReceiver.prototype.extend({
|
||||||
return this.handleVerified(envelope, syncMessage.verified);
|
return this.handleVerified(envelope, syncMessage.verified);
|
||||||
} else if (syncMessage.configuration) {
|
} else if (syncMessage.configuration) {
|
||||||
return this.handleConfiguration(envelope, syncMessage.configuration);
|
return this.handleConfiguration(envelope, syncMessage.configuration);
|
||||||
} else if (syncMessage.stickerPackOperation) {
|
} else if (
|
||||||
|
syncMessage.stickerPackOperation &&
|
||||||
|
syncMessage.stickerPackOperation.length > 0
|
||||||
|
) {
|
||||||
return this.handleStickerPackOperation(
|
return this.handleStickerPackOperation(
|
||||||
envelope,
|
envelope,
|
||||||
syncMessage.stickerPackOperation
|
syncMessage.stickerPackOperation
|
||||||
);
|
);
|
||||||
|
} else if (syncMessage.messageTimerRead) {
|
||||||
|
return this.handleMessageTimerRead(
|
||||||
|
envelope,
|
||||||
|
syncMessage.messageTimerRead
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error('Got empty SyncMessage');
|
throw new Error('Got empty SyncMessage');
|
||||||
},
|
},
|
||||||
|
@ -1125,6 +1133,17 @@ MessageReceiver.prototype.extend({
|
||||||
ev.configuration = configuration;
|
ev.configuration = configuration;
|
||||||
return this.dispatchAndWait(ev);
|
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) {
|
handleStickerPackOperation(envelope, operations) {
|
||||||
const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type;
|
const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type;
|
||||||
window.log.info('got sticker pack operation sync message');
|
window.log.info('got sticker pack operation sync message');
|
||||||
|
|
|
@ -750,6 +750,34 @@ MessageSender.prototype = {
|
||||||
|
|
||||||
return Promise.resolve();
|
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) {
|
async sendStickerPackSync(operations, options) {
|
||||||
const myDevice = textsecure.storage.user.getDeviceId();
|
const myDevice = textsecure.storage.user.getDeviceId();
|
||||||
if (myDevice === 1 || myDevice === '1') {
|
if (myDevice === 1 || myDevice === '1') {
|
||||||
|
@ -1238,6 +1266,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
|
||||||
this.getSticker = sender.getSticker.bind(sender);
|
this.getSticker = sender.getSticker.bind(sender);
|
||||||
this.getStickerPackManifest = sender.getStickerPackManifest.bind(sender);
|
this.getStickerPackManifest = sender.getStickerPackManifest.bind(sender);
|
||||||
this.sendStickerPackSync = sender.sendStickerPackSync.bind(sender);
|
this.sendStickerPackSync = sender.sendStickerPackSync.bind(sender);
|
||||||
|
this.syncMessageTimerRead = sender.syncMessageTimerRead.bind(sender);
|
||||||
};
|
};
|
||||||
|
|
||||||
textsecure.MessageSender.prototype = {
|
textsecure.MessageSender.prototype = {
|
||||||
|
|
|
@ -173,7 +173,8 @@ message DataMessage {
|
||||||
option allow_alias = true;
|
option allow_alias = true;
|
||||||
|
|
||||||
INITIAL = 0;
|
INITIAL = 0;
|
||||||
CURRENT = 0;
|
MESSAGE_TIMERS = 1;
|
||||||
|
CURRENT = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional string body = 1;
|
optional string body = 1;
|
||||||
|
@ -188,6 +189,7 @@ message DataMessage {
|
||||||
repeated Preview preview = 10;
|
repeated Preview preview = 10;
|
||||||
optional Sticker sticker = 11;
|
optional Sticker sticker = 11;
|
||||||
optional uint32 requiredProtocolVersion = 12;
|
optional uint32 requiredProtocolVersion = 12;
|
||||||
|
optional uint32 messageTimer = 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NullMessage {
|
message NullMessage {
|
||||||
|
@ -291,6 +293,11 @@ message SyncMessage {
|
||||||
optional Type type = 3;
|
optional Type type = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message MessageTimerRead {
|
||||||
|
optional string sender = 1;
|
||||||
|
optional uint64 timestamp = 2;
|
||||||
|
}
|
||||||
|
|
||||||
optional Sent sent = 1;
|
optional Sent sent = 1;
|
||||||
optional Contacts contacts = 2;
|
optional Contacts contacts = 2;
|
||||||
optional Groups groups = 3;
|
optional Groups groups = 3;
|
||||||
|
@ -301,6 +308,7 @@ message SyncMessage {
|
||||||
optional Configuration configuration = 9;
|
optional Configuration configuration = 9;
|
||||||
optional bytes padding = 8;
|
optional bytes padding = 8;
|
||||||
repeated StickerPackOperation stickerPackOperation = 10;
|
repeated StickerPackOperation stickerPackOperation = 10;
|
||||||
|
optional MessageTimerRead messageTimerRead = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AttachmentPointer {
|
message AttachmentPointer {
|
||||||
|
|
|
@ -13,6 +13,48 @@
|
||||||
color: $color-gray-90;
|
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 {
|
.module-message__author {
|
||||||
color: $color-gray-90;
|
color: $color-gray-90;
|
||||||
}
|
}
|
||||||
|
@ -46,19 +88,22 @@
|
||||||
.module-message__metadata__date--with-sticker {
|
.module-message__metadata__date--with-sticker {
|
||||||
color: $color-gray-60;
|
color: $color-gray-60;
|
||||||
}
|
}
|
||||||
|
.module-message__metadata__date--outgoing-with-tap-to-view-expired {
|
||||||
|
color: $color-gray-75;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__metadata__status-icon--sending {
|
.module-message__metadata__status-icon--sending {
|
||||||
@include color-svg('../images/sending.svg', $color-white);
|
background-color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__metadata__status-icon--sent {
|
.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 {
|
.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 {
|
.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 {
|
.module-message__metadata__status-icon--with-image-no-caption {
|
||||||
|
@ -67,6 +112,9 @@
|
||||||
.module-message__metadata__status-icon--with-sticker {
|
.module-message__metadata__status-icon--with-sticker {
|
||||||
background-color: $color-gray-60;
|
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 {
|
.module-message__generic-attachment__file-name {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
|
@ -93,6 +141,9 @@
|
||||||
.module-expire-timer--with-sticker {
|
.module-expire-timer--with-sticker {
|
||||||
background-color: $color-gray-60;
|
background-color: $color-gray-60;
|
||||||
}
|
}
|
||||||
|
.module-expire-timer--outgoing-with-tap-to-view-expired {
|
||||||
|
background-color: $color-gray-75;
|
||||||
|
}
|
||||||
|
|
||||||
.module-quote--outgoing {
|
.module-quote--outgoing {
|
||||||
border-left-color: $color-white;
|
border-left-color: $color-white;
|
||||||
|
@ -167,6 +218,16 @@
|
||||||
color: $color-gray-05;
|
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 {
|
.module-message__author {
|
||||||
color: $color-gray-05;
|
color: $color-gray-05;
|
||||||
}
|
}
|
||||||
|
@ -180,17 +241,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__metadata__status-icon--sending {
|
.module-message__metadata__status-icon--sending {
|
||||||
@include color-svg('../images/sending.svg', $color-white);
|
background-color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__metadata__status-icon--sent {
|
.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 {
|
.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 {
|
.module-message__metadata__status-icon--read {
|
||||||
@include color-svg('../images/read.svg', $color-white-08);
|
background-color: $color-white-08;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__metadata__date {
|
.module-message__metadata__date {
|
||||||
|
|
|
@ -204,6 +204,121 @@
|
||||||
background-color: $color-conversation-blue_grey;
|
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 {
|
.module-message__attachment-container {
|
||||||
// To ensure that images are centered if they aren't full width of bubble
|
// To ensure that images are centered if they aren't full width of bubble
|
||||||
text-align: center;
|
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 {
|
.module-message__author_with_sticker {
|
||||||
color: $color-gray-90;
|
color: $color-gray-90;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
@ -555,6 +686,9 @@
|
||||||
.module-message__metadata__date--with-image-no-caption {
|
.module-message__metadata__date--with-image-no-caption {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
|
.module-message__metadata__date--incoming-with-tap-to-view-expired {
|
||||||
|
color: $color-gray-75;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__metadata__spacer {
|
.module-message__metadata__spacer {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -683,6 +817,9 @@
|
||||||
.module-expire-timer--incoming {
|
.module-expire-timer--incoming {
|
||||||
background-color: $color-white-08;
|
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
|
// When status indicators are overlaid on top of an image, they use different colors
|
||||||
.module-expire-timer--with-image-no-caption {
|
.module-expire-timer--with-image-no-caption {
|
||||||
|
@ -2813,8 +2950,8 @@
|
||||||
|
|
||||||
@include color-svg('../images/spinner-track-56.svg', $color-white-04);
|
@include color-svg('../images/spinner-track-56.svg', $color-white-04);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
height: 56px;
|
height: 100%;
|
||||||
width: 56px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.module-spinner__arc {
|
.module-spinner__arc {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -2823,8 +2960,8 @@
|
||||||
|
|
||||||
@include color-svg('../images/spinner-56.svg', $color-gray-60);
|
@include color-svg('../images/spinner-56.svg', $color-gray-60);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
height: 56px;
|
height: 100%;
|
||||||
width: 56px;
|
width: 100%;
|
||||||
|
|
||||||
animation: spinner-arc-animation 1000ms linear infinite;
|
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
|
// In these --small and --mini sizes, we're exploding our @color-svg mixin so we don't
|
||||||
// have to duplicate our background colors for the dark/ios/size matrix.
|
// have to duplicate our background colors for the dark/ios/size matrix.
|
||||||
|
|
||||||
.module-spinner__container--small {
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
.module-spinner__circle--small {
|
.module-spinner__circle--small {
|
||||||
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
|
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
|
||||||
-webkit-mask-size: 100%;
|
-webkit-mask-size: 100%;
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
}
|
}
|
||||||
.module-spinner__arc--small {
|
.module-spinner__arc--small {
|
||||||
-webkit-mask: url('../images/spinner-24.svg') no-repeat center;
|
-webkit-mask: url('../images/spinner-24.svg') no-repeat center;
|
||||||
-webkit-mask-size: 100%;
|
-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 {
|
.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
|
// Third-party module: react-contextmenu
|
||||||
|
|
||||||
.react-contextmenu {
|
.react-contextmenu {
|
||||||
|
|
|
@ -579,6 +579,75 @@ body.dark-theme {
|
||||||
background-color: $color-conversation-blue_grey;
|
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 {
|
.module-message__attachment-container {
|
||||||
background-color: $color-gray-95;
|
background-color: $color-gray-95;
|
||||||
}
|
}
|
||||||
|
@ -674,6 +743,10 @@ body.dark-theme {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__author--with-tap-to-view-expired {
|
||||||
|
color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__author_with_sticker {
|
.module-message__author_with_sticker {
|
||||||
color: $color-gray-05;
|
color: $color-gray-05;
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ $roboto-light: Roboto-Light, 'Helvetica Neue', 'Source Sans Pro Light',
|
||||||
$color-signal-blue: #2090ea;
|
$color-signal-blue: #2090ea;
|
||||||
$color-core-green: #4caf50;
|
$color-core-green: #4caf50;
|
||||||
$color-core-red: #f44336;
|
$color-core-red: #f44336;
|
||||||
|
$color-deep-red: #ff261f;
|
||||||
|
|
||||||
$color-signal-blue-025: rgba($color-signal-blue, 0.25);
|
$color-signal-blue-025: rgba($color-signal-blue, 0.25);
|
||||||
$color-signal-blue-050: rgba($color-signal-blue, 0.5);
|
$color-signal-blue-050: rgba($color-signal-blue, 0.5);
|
||||||
|
|
|
@ -474,6 +474,7 @@
|
||||||
<script type="text/javascript" src="../js/message_controller.js" data-cover></script>
|
<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/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_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/notifications.js' data-cover></script>
|
||||||
<script type='text/javascript' src='../js/focus_listener.js'></script>
|
<script type='text/javascript' src='../js/focus_listener.js'></script>
|
||||||
|
|
||||||
|
|
23
ts/components/Countdown.md
Normal file
23
ts/components/Countdown.md
Normal 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>
|
||||||
|
```
|
99
ts/components/Countdown.tsx
Normal file
99
ts/components/Countdown.tsx
Normal 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);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
## Image
|
## Image
|
||||||
|
|
||||||
```js
|
```jsx
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||||
|
@ -15,7 +15,7 @@ const noop = () => {};
|
||||||
|
|
||||||
## Image with caption
|
## Image with caption
|
||||||
|
|
||||||
```js
|
```jsx
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||||
|
@ -29,9 +29,27 @@ const noop = () => {};
|
||||||
</div>;
|
</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)
|
## Image (unsupported format)
|
||||||
|
|
||||||
```js
|
```jsx
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||||
|
@ -46,7 +64,7 @@ const noop = () => {};
|
||||||
|
|
||||||
## Video (supported format)
|
## Video (supported format)
|
||||||
|
|
||||||
```js
|
```jsx
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||||
|
@ -61,7 +79,7 @@ const noop = () => {};
|
||||||
|
|
||||||
## Video (unsupported format)
|
## Video (unsupported format)
|
||||||
|
|
||||||
```js
|
```jsx
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||||
|
@ -76,7 +94,7 @@ const noop = () => {};
|
||||||
|
|
||||||
## Unsupported file format
|
## Unsupported file format
|
||||||
|
|
||||||
```js
|
```jsx
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: '100%', height: 600 }}>
|
<div style={{ position: 'relative', width: '100%', height: 600 }}>
|
||||||
|
|
|
@ -104,6 +104,9 @@ const styles = {
|
||||||
saveButton: {
|
saveButton: {
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
},
|
},
|
||||||
|
countdownContainer: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
iconButtonPlaceholder: {
|
iconButtonPlaceholder: {
|
||||||
// Dimensions match `.iconButton`:
|
// Dimensions match `.iconButton`:
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
@ -211,11 +214,11 @@ export class Lightbox extends React.Component<Props> {
|
||||||
const {
|
const {
|
||||||
caption,
|
caption,
|
||||||
contentType,
|
contentType,
|
||||||
|
i18n,
|
||||||
objectURL,
|
objectURL,
|
||||||
onNext,
|
onNext,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
onSave,
|
onSave,
|
||||||
i18n,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,32 +1,47 @@
|
||||||
#### Large
|
#### Normal, no size
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Spinner size="normal" />
|
<Spinner svgSize="normal" />
|
||||||
<div style={{ backgroundColor: '#2090ea' }}>
|
<div style={{ backgroundColor: '#2090ea' }}>
|
||||||
<Spinner size="normal" />
|
<Spinner svgSize="normal" />
|
||||||
</div>
|
</div>
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Small
|
#### Normal, with size
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Spinner size="small" />
|
<Spinner svgSize="normal" size="100px" />
|
||||||
<div style={{ backgroundColor: '#2090ea' }}>
|
<div style={{ backgroundColor: '#2090ea' }}>
|
||||||
<Spinner size="small" />
|
<Spinner svgSize="normal" size="100px" />
|
||||||
</div>
|
</div>
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Mini
|
#### Small, no size
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Spinner size="mini" />
|
<Spinner svgSize="small" />
|
||||||
<div style={{ backgroundColor: '#2090ea' }}>
|
<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>
|
</div>
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
|
@ -2,37 +2,44 @@ import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
size: 'small' | 'mini' | 'normal';
|
size?: string;
|
||||||
|
svgSize: 'small' | 'normal';
|
||||||
direction?: string;
|
direction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Spinner extends React.Component<Props> {
|
export class Spinner extends React.Component<Props> {
|
||||||
public render() {
|
public render() {
|
||||||
const { size, direction } = this.props;
|
const { size, svgSize, direction } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-spinner__container',
|
'module-spinner__container',
|
||||||
`module-spinner__container--${size}`,
|
`module-spinner__container--${svgSize}`,
|
||||||
direction ? `module-spinner__container--${direction}` : null,
|
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
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-spinner__circle',
|
'module-spinner__circle',
|
||||||
`module-spinner__circle--${size}`,
|
`module-spinner__circle--${svgSize}`,
|
||||||
direction ? `module-spinner__circle--${direction}` : null,
|
direction ? `module-spinner__circle--${direction}` : null,
|
||||||
direction ? `module-spinner__circle--${size}-${direction}` : null
|
direction ? `module-spinner__circle--${svgSize}-${direction}` : null
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-spinner__arc',
|
'module-spinner__arc',
|
||||||
`module-spinner__arc--${size}`,
|
`module-spinner__arc--${svgSize}`,
|
||||||
direction ? `module-spinner__arc--${direction}` : null,
|
direction ? `module-spinner__arc--${direction}` : null,
|
||||||
direction ? `module-spinner__arc--${size}-${direction}` : null
|
direction ? `module-spinner__arc--${svgSize}-${direction}` : null
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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
|
### Empty contact
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
|
|
@ -4,8 +4,9 @@ import classNames from 'classnames';
|
||||||
import { getIncrement, getTimerBucket } from '../../util/timer';
|
import { getIncrement, getTimerBucket } from '../../util/timer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
withImageNoCaption: boolean;
|
withImageNoCaption?: boolean;
|
||||||
withSticker: boolean;
|
withSticker?: boolean;
|
||||||
|
withTapToViewExpired?: boolean;
|
||||||
expirationLength: number;
|
expirationLength: number;
|
||||||
expirationTimestamp: number;
|
expirationTimestamp: number;
|
||||||
direction?: 'incoming' | 'outgoing';
|
direction?: 'incoming' | 'outgoing';
|
||||||
|
@ -46,6 +47,7 @@ export class ExpireTimer extends React.Component<Props> {
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
withImageNoCaption,
|
withImageNoCaption,
|
||||||
withSticker,
|
withSticker,
|
||||||
|
withTapToViewExpired,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||||
|
@ -55,8 +57,11 @@ export class ExpireTimer extends React.Component<Props> {
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-expire-timer',
|
'module-expire-timer',
|
||||||
`module-expire-timer--${bucket}`,
|
`module-expire-timer--${bucket}`,
|
||||||
`module-expire-timer--${direction}`,
|
direction ? `module-expire-timer--${direction}` : null,
|
||||||
withImageNoCaption
|
withTapToViewExpired
|
||||||
|
? `module-expire-timer--${direction}-with-tap-to-view-expired`
|
||||||
|
: null,
|
||||||
|
direction && withImageNoCaption
|
||||||
? 'module-expire-timer--with-image-no-caption'
|
? 'module-expire-timer--with-image-no-caption'
|
||||||
: null,
|
: null,
|
||||||
withSticker ? 'module-expire-timer--with-sticker' : null
|
withSticker ? 'module-expire-timer--with-sticker' : null
|
||||||
|
|
|
@ -99,7 +99,7 @@ export class Image extends React.Component<Props> {
|
||||||
}}
|
}}
|
||||||
// alt={i18n('loading')}
|
// alt={i18n('loading')}
|
||||||
>
|
>
|
||||||
<Spinner size="normal" />
|
<Spinner svgSize="normal" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
|
|
|
@ -3565,6 +3565,440 @@ Sticker link previews are forced to use the small link preview form, no matter t
|
||||||
</util.ConversationContext>
|
</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
|
### In a group conversation
|
||||||
|
|
||||||
Note that the author avatar goes away if `collapseMetadata` is set.
|
Note that the author avatar goes away if `collapseMetadata` is set.
|
||||||
|
|
|
@ -80,6 +80,11 @@ export type PropsData = {
|
||||||
previews: Array<LinkPreviewType>;
|
previews: Array<LinkPreviewType>;
|
||||||
authorAvatarPath?: string;
|
authorAvatarPath?: string;
|
||||||
isExpired: boolean;
|
isExpired: boolean;
|
||||||
|
|
||||||
|
isTapToView?: boolean;
|
||||||
|
isTapToViewExpired?: boolean;
|
||||||
|
isTapToViewError?: boolean;
|
||||||
|
|
||||||
expirationLength?: number;
|
expirationLength?: number;
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
};
|
};
|
||||||
|
@ -112,6 +117,7 @@ export type PropsActions = {
|
||||||
isDangerous: boolean;
|
isDangerous: boolean;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
|
displayTapToViewMessage: (messageId: string) => unknown;
|
||||||
|
|
||||||
openLink: (url: string) => void;
|
openLink: (url: string) => void;
|
||||||
scrollToMessage: (
|
scrollToMessage: (
|
||||||
|
@ -227,6 +233,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
i18n,
|
i18n,
|
||||||
isSticker,
|
isSticker,
|
||||||
|
isTapToViewExpired,
|
||||||
status,
|
status,
|
||||||
text,
|
text,
|
||||||
textPending,
|
textPending,
|
||||||
|
@ -274,6 +281,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
direction={metadataDirection}
|
direction={metadataDirection}
|
||||||
withImageNoCaption={withImageNoCaption}
|
withImageNoCaption={withImageNoCaption}
|
||||||
withSticker={isSticker}
|
withSticker={isSticker}
|
||||||
|
withTapToViewExpired={isTapToViewExpired}
|
||||||
module="module-message__metadata__date"
|
module="module-message__metadata__date"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -284,12 +292,13 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
expirationTimestamp={expirationTimestamp}
|
expirationTimestamp={expirationTimestamp}
|
||||||
withImageNoCaption={withImageNoCaption}
|
withImageNoCaption={withImageNoCaption}
|
||||||
withSticker={isSticker}
|
withSticker={isSticker}
|
||||||
|
withTapToViewExpired={isTapToViewExpired}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="module-message__metadata__spacer" />
|
<span className="module-message__metadata__spacer" />
|
||||||
{textPending ? (
|
{textPending ? (
|
||||||
<div className="module-message__metadata__spinner-container">
|
<div className="module-message__metadata__spinner-container">
|
||||||
<Spinner size="mini" direction={direction} />
|
<Spinner svgSize="small" size="14px" direction={direction} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!textPending && direction === 'outgoing' && status !== 'error' ? (
|
{!textPending && direction === 'outgoing' && status !== 'error' ? (
|
||||||
|
@ -302,6 +311,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
: null,
|
: null,
|
||||||
withImageNoCaption
|
withImageNoCaption
|
||||||
? 'module-message__metadata__status-icon--with-image-no-caption'
|
? 'module-message__metadata__status-icon--with-image-no-caption'
|
||||||
|
: null,
|
||||||
|
isTapToViewExpired
|
||||||
|
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -320,6 +332,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
direction,
|
direction,
|
||||||
i18n,
|
i18n,
|
||||||
isSticker,
|
isSticker,
|
||||||
|
isTapToView,
|
||||||
|
isTapToViewExpired,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (collapseMetadata) {
|
if (collapseMetadata) {
|
||||||
|
@ -332,8 +346,13 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffix = isSticker ? '_with_sticker' : '';
|
const withTapToViewExpired = isTapToView && isTapToViewExpired;
|
||||||
const moduleName = `module-message__author${suffix}`;
|
|
||||||
|
const stickerSuffix = isSticker ? '_with_sticker' : '';
|
||||||
|
const tapToViewSuffix = withTapToViewExpired
|
||||||
|
? '--with-tap-to-view-expired'
|
||||||
|
: '';
|
||||||
|
const moduleName = `module-message__author${stickerSuffix}${tapToViewSuffix}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={moduleName}>
|
<div className={moduleName}>
|
||||||
|
@ -452,7 +471,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
>
|
>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<div className="module-message__generic-attachment__spinner-container">
|
<div className="module-message__generic-attachment__spinner-container">
|
||||||
<Spinner size="small" direction={direction} />
|
<Spinner svgSize="small" size="24px" direction={direction} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="module-message__generic-attachment__icon-container">
|
<div className="module-message__generic-attachment__icon-container">
|
||||||
|
@ -805,6 +824,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
downloadAttachment,
|
downloadAttachment,
|
||||||
id,
|
id,
|
||||||
isSticker,
|
isSticker,
|
||||||
|
isTapToView,
|
||||||
replyToMessage,
|
replyToMessage,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
@ -822,6 +842,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
const downloadButton =
|
const downloadButton =
|
||||||
!isSticker &&
|
!isSticker &&
|
||||||
!multipleAttachments &&
|
!multipleAttachments &&
|
||||||
|
!isTapToView &&
|
||||||
firstAttachment &&
|
firstAttachment &&
|
||||||
!firstAttachment.pending ? (
|
!firstAttachment.pending ? (
|
||||||
<div
|
<div
|
||||||
|
@ -886,15 +907,16 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
public renderContextMenu(triggerId: string) {
|
public renderContextMenu(triggerId: string) {
|
||||||
const {
|
const {
|
||||||
attachments,
|
attachments,
|
||||||
|
deleteMessage,
|
||||||
direction,
|
direction,
|
||||||
downloadAttachment,
|
downloadAttachment,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
isSticker,
|
isSticker,
|
||||||
deleteMessage,
|
isTapToView,
|
||||||
showMessageDetail,
|
|
||||||
replyToMessage,
|
replyToMessage,
|
||||||
retrySend,
|
retrySend,
|
||||||
|
showMessageDetail,
|
||||||
status,
|
status,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
@ -907,7 +929,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<ContextMenu id={triggerId}>
|
<ContextMenu id={triggerId}>
|
||||||
{!isSticker && !multipleAttachments && attachments && attachments[0] ? (
|
{!isSticker &&
|
||||||
|
!multipleAttachments &&
|
||||||
|
!isTapToView &&
|
||||||
|
attachments &&
|
||||||
|
attachments[0] ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
attributes={{
|
attributes={{
|
||||||
className: 'module-message__context__download',
|
className: 'module-message__context__download',
|
||||||
|
@ -1011,10 +1037,10 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public isShowingImage() {
|
public isShowingImage() {
|
||||||
const { attachments, previews } = this.props;
|
const { isTapToView, attachments, previews } = this.props;
|
||||||
const { imageBroken } = this.state;
|
const { imageBroken } = this.state;
|
||||||
|
|
||||||
if (imageBroken) {
|
if (imageBroken || isTapToView) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1042,17 +1068,153 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return false;
|
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() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
authorPhoneNumber,
|
authorPhoneNumber,
|
||||||
authorColor,
|
authorColor,
|
||||||
attachments,
|
attachments,
|
||||||
direction,
|
direction,
|
||||||
|
displayTapToViewMessage,
|
||||||
id,
|
id,
|
||||||
isSticker,
|
isSticker,
|
||||||
|
isTapToView,
|
||||||
|
isTapToViewExpired,
|
||||||
|
isTapToViewError,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { expired, expiring, imageBroken } = this.state;
|
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.
|
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||||
// It needs to be unique.
|
// It needs to be unique.
|
||||||
|
@ -1068,6 +1230,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
const width = this.getWidth();
|
const width = this.getWidth();
|
||||||
const isShowingImage = this.isShowingImage();
|
const isShowingImage = this.isShowingImage();
|
||||||
|
const role = isButton ? 'button' : undefined;
|
||||||
|
const onClick = isButton ? () => displayTapToViewMessage(id) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -1084,22 +1248,31 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
'module-message__container',
|
'module-message__container',
|
||||||
isSticker ? 'module-message__container--with-sticker' : null,
|
isSticker ? 'module-message__container--with-sticker' : null,
|
||||||
!isSticker ? `module-message__container--${direction}` : 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'
|
!isSticker && direction === 'incoming'
|
||||||
? `module-message__container--incoming-${authorColor}`
|
? `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
|
: null
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: isShowingImage ? width : undefined,
|
width: isShowingImage ? width : undefined,
|
||||||
}}
|
}}
|
||||||
|
role={role}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{this.renderAuthor()}
|
{this.renderAuthor()}
|
||||||
{this.renderQuote()}
|
{this.renderContents()}
|
||||||
{this.renderAttachment()}
|
|
||||||
{this.renderPreview()}
|
|
||||||
{this.renderEmbeddedContact()}
|
|
||||||
{this.renderText()}
|
|
||||||
{this.renderMetadata()}
|
|
||||||
{this.renderSendMessageButton()}
|
|
||||||
{this.renderAvatar()}
|
{this.renderAvatar()}
|
||||||
</div>
|
</div>
|
||||||
{this.renderError(direction === 'outgoing')}
|
{this.renderError(direction === 'outgoing')}
|
||||||
|
|
|
@ -12,6 +12,7 @@ interface Props {
|
||||||
module?: string;
|
module?: string;
|
||||||
withImageNoCaption?: boolean;
|
withImageNoCaption?: boolean;
|
||||||
withSticker?: boolean;
|
withSticker?: boolean;
|
||||||
|
withTapToViewExpired?: boolean;
|
||||||
direction?: 'incoming' | 'outgoing';
|
direction?: 'incoming' | 'outgoing';
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
}
|
}
|
||||||
|
@ -50,6 +51,7 @@ export class Timestamp extends React.Component<Props> {
|
||||||
timestamp,
|
timestamp,
|
||||||
withImageNoCaption,
|
withImageNoCaption,
|
||||||
withSticker,
|
withSticker,
|
||||||
|
withTapToViewExpired,
|
||||||
extended,
|
extended,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const moduleName = module || 'module-timestamp';
|
const moduleName = module || 'module-timestamp';
|
||||||
|
@ -63,6 +65,9 @@ export class Timestamp extends React.Component<Props> {
|
||||||
className={classNames(
|
className={classNames(
|
||||||
moduleName,
|
moduleName,
|
||||||
direction ? `${moduleName}--${direction}` : null,
|
direction ? `${moduleName}--${direction}` : null,
|
||||||
|
withTapToViewExpired && direction
|
||||||
|
? `${moduleName}--${direction}-with-tap-to-view-expired`
|
||||||
|
: null,
|
||||||
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
|
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
|
||||||
withSticker ? `${moduleName}--with-sticker` : null
|
withSticker ? `${moduleName}--with-sticker` : null
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -25,12 +25,17 @@ export function renderAvatar({
|
||||||
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
||||||
const pending = avatar && avatar.avatar && avatar.avatar.pending;
|
const pending = avatar && avatar.avatar && avatar.avatar.pending;
|
||||||
const name = getName(contact) || '';
|
const name = getName(contact) || '';
|
||||||
const spinnerSize = size < 50 ? 'small' : 'normal';
|
const spinnerSvgSize = size < 50 ? 'small' : 'normal';
|
||||||
|
const spinnerSize = size < 50 ? '24px' : undefined;
|
||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
return (
|
return (
|
||||||
<div className="module-embedded-contact__spinner-container">
|
<div className="module-embedded-contact__spinner-container">
|
||||||
<Spinner size={spinnerSize} direction={direction} />
|
<Spinner
|
||||||
|
svgSize={spinnerSvgSize}
|
||||||
|
size={spinnerSize}
|
||||||
|
direction={direction}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ function renderBody({ pack, i18n }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pack || pack.stickerCount === 0 || !isNumber(pack.stickerCount)) {
|
if (!pack || pack.stickerCount === 0 || !isNumber(pack.stickerCount)) {
|
||||||
return <Spinner size="normal" />;
|
return <Spinner svgSize="normal" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -209,7 +209,7 @@ export const StickerPreviewModal = React.memo(
|
||||||
</div>
|
</div>
|
||||||
<div className="module-sticker-manager__preview-modal__container__meta-overlay__install">
|
<div className="module-sticker-manager__preview-modal__container__meta-overlay__install">
|
||||||
{pack.status === 'pending' ? (
|
{pack.status === 'pending' ? (
|
||||||
<Spinner size="mini" />
|
<Spinner svgSize="small" size="14px" />
|
||||||
) : (
|
) : (
|
||||||
<StickerPackInstallButton
|
<StickerPackInstallButton
|
||||||
ref={focusRef}
|
ref={focusRef}
|
||||||
|
|
|
@ -18,6 +18,7 @@ export type IncomingMessage = Readonly<
|
||||||
decrypted_at?: number;
|
decrypted_at?: number;
|
||||||
errors?: Array<any>;
|
errors?: Array<any>;
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
|
messageTimer?: number;
|
||||||
flags?: number;
|
flags?: number;
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceDevice?: number;
|
sourceDevice?: number;
|
||||||
|
@ -46,6 +47,7 @@ export type OutgoingMessage = Readonly<
|
||||||
body?: string;
|
body?: string;
|
||||||
expires_at?: number;
|
expires_at?: number;
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
|
messageTimer?: number;
|
||||||
recipients?: Array<string>; // Array<PhoneNumber>
|
recipients?: Array<string>; // Array<PhoneNumber>
|
||||||
synced: boolean;
|
synced: boolean;
|
||||||
} & SharedMessageProperties &
|
} & SharedMessageProperties &
|
||||||
|
|
|
@ -16,6 +16,9 @@ export const initializeAttachmentMetadata = async (
|
||||||
if (message.type === 'verified-change') {
|
if (message.type === 'verified-change') {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
if (message.messageTimer) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
const attachments = message.attachments.filter(
|
const attachments = message.attachments.filter(
|
||||||
(attachment: Attachment.Attachment) =>
|
(attachment: Attachment.Attachment) =>
|
||||||
|
|
|
@ -6095,7 +6095,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/Lightbox.js",
|
"path": "ts/components/Lightbox.js",
|
||||||
"line": " this.videoRef = react_1.default.createRef();",
|
"line": " this.videoRef = react_1.default.createRef();",
|
||||||
"lineNumber": 180,
|
"lineNumber": 183,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2019-03-09T00:08:44.242Z",
|
"updated": "2019-03-09T00:08:44.242Z",
|
||||||
"reasonDetail": "Used to auto-start playback on videos"
|
"reasonDetail": "Used to auto-start playback on videos"
|
||||||
|
@ -6104,7 +6104,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/Lightbox.tsx",
|
"path": "ts/components/Lightbox.tsx",
|
||||||
"line": " this.videoRef = React.createRef();",
|
"line": " this.videoRef = React.createRef();",
|
||||||
"lineNumber": 176,
|
"lineNumber": 179,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2019-03-09T00:08:44.242Z",
|
"updated": "2019-03-09T00:08:44.242Z",
|
||||||
"reasonDetail": "Used to auto-start playback on videos"
|
"reasonDetail": "Used to auto-start playback on videos"
|
||||||
|
|
Loading…
Add table
Reference in a new issue