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":
|
||||
"Shown in notifications and in the left pane when a message has features too new for this signal install."
|
||||
},
|
||||
"message--getDescription--disappearing-photo": {
|
||||
"message": "Disappearing photo",
|
||||
"description":
|
||||
"Shown in notifications and in the left pane when a message is a disappearing photo."
|
||||
},
|
||||
"stickers--toast--InstallFailed": {
|
||||
"message": "Sticker pack could not be installed",
|
||||
"description":
|
||||
|
@ -1901,5 +1906,20 @@
|
|||
"message": "Update Signal",
|
||||
"description":
|
||||
"Text for a button which will take user to Signal download page"
|
||||
},
|
||||
"Message--tap-to-view-expired": {
|
||||
"message": "Viewed",
|
||||
"description":
|
||||
"Text shown on messages with with individual timers, after user has viewed it"
|
||||
},
|
||||
"Message--tap-to-view--outgoing": {
|
||||
"message": "Photo",
|
||||
"description":
|
||||
"Text shown on outgoing messages with with individual timers (inaccessble)"
|
||||
},
|
||||
"Message--tap-to-view--incoming": {
|
||||
"message": "View Photo",
|
||||
"description":
|
||||
"Text shown on messages with with individual timers, before user has viewed it"
|
||||
}
|
||||
}
|
||||
|
|
171
app/sql.js
171
app/sql.js
|
@ -94,6 +94,9 @@ module.exports = {
|
|||
getOutgoingWithoutExpiresAt,
|
||||
getNextExpiringMessage,
|
||||
getMessagesByConversation,
|
||||
getNextTapToViewMessageToExpire,
|
||||
getNextTapToViewMessageToAgeOut,
|
||||
getTapToViewMessagesNeedingErase,
|
||||
|
||||
getUnprocessedCount,
|
||||
getAllUnprocessed,
|
||||
|
@ -868,6 +871,87 @@ async function updateToSchemaVersion15(currentVersion, instance) {
|
|||
}
|
||||
}
|
||||
|
||||
async function updateToSchemaVersion16(currentVersion, instance) {
|
||||
if (currentVersion >= 16) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('updateToSchemaVersion16: starting...');
|
||||
await instance.run('BEGIN TRANSACTION;');
|
||||
|
||||
try {
|
||||
await instance.run(
|
||||
`ALTER TABLE messages
|
||||
ADD COLUMN messageTimer INTEGER;`
|
||||
);
|
||||
await instance.run(
|
||||
`ALTER TABLE messages
|
||||
ADD COLUMN messageTimerStart INTEGER;`
|
||||
);
|
||||
await instance.run(
|
||||
`ALTER TABLE messages
|
||||
ADD COLUMN messageTimerExpiresAt INTEGER;`
|
||||
);
|
||||
await instance.run(
|
||||
`ALTER TABLE messages
|
||||
ADD COLUMN isErased INTEGER;`
|
||||
);
|
||||
|
||||
await instance.run(`CREATE INDEX messages_message_timer ON messages (
|
||||
messageTimer,
|
||||
messageTimerStart,
|
||||
messageTimerExpiresAt,
|
||||
isErased
|
||||
) WHERE messageTimer IS NOT NULL;`);
|
||||
|
||||
// Updating full-text triggers to avoid anything with a messageTimer set
|
||||
|
||||
await instance.run('DROP TRIGGER messages_on_insert;');
|
||||
await instance.run('DROP TRIGGER messages_on_delete;');
|
||||
await instance.run('DROP TRIGGER messages_on_update;');
|
||||
|
||||
await instance.run(`
|
||||
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
|
||||
WHEN new.messageTimer IS NULL
|
||||
BEGIN
|
||||
INSERT INTO messages_fts (
|
||||
id,
|
||||
body
|
||||
) VALUES (
|
||||
new.id,
|
||||
new.body
|
||||
);
|
||||
END;
|
||||
`);
|
||||
await instance.run(`
|
||||
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE id = old.id;
|
||||
END;
|
||||
`);
|
||||
await instance.run(`
|
||||
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
|
||||
WHEN new.messageTimer IS NULL
|
||||
BEGIN
|
||||
DELETE FROM messages_fts WHERE id = old.id;
|
||||
INSERT INTO messages_fts(
|
||||
id,
|
||||
body
|
||||
) VALUES (
|
||||
new.id,
|
||||
new.body
|
||||
);
|
||||
END;
|
||||
`);
|
||||
|
||||
await instance.run('PRAGMA schema_version = 16;');
|
||||
await instance.run('COMMIT TRANSACTION;');
|
||||
console.log('updateToSchemaVersion16: success!');
|
||||
} catch (error) {
|
||||
await instance.run('ROLLBACK;');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEMA_VERSIONS = [
|
||||
updateToSchemaVersion1,
|
||||
updateToSchemaVersion2,
|
||||
|
@ -884,6 +968,7 @@ const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion13,
|
||||
updateToSchemaVersion14,
|
||||
updateToSchemaVersion15,
|
||||
updateToSchemaVersion16,
|
||||
];
|
||||
|
||||
async function updateSchema(instance) {
|
||||
|
@ -1480,6 +1565,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
|||
hasFileAttachments,
|
||||
hasVisualMediaAttachments,
|
||||
id,
|
||||
isErased,
|
||||
messageTimer,
|
||||
messageTimerStart,
|
||||
messageTimerExpiresAt,
|
||||
// eslint-disable-next-line camelcase
|
||||
received_at,
|
||||
schemaVersion,
|
||||
|
@ -1505,6 +1594,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
|||
$hasAttachments: hasAttachments,
|
||||
$hasFileAttachments: hasFileAttachments,
|
||||
$hasVisualMediaAttachments: hasVisualMediaAttachments,
|
||||
$isErased: isErased,
|
||||
$messageTimer: messageTimer,
|
||||
$messageTimerStart: messageTimerStart,
|
||||
$messageTimerExpiresAt: messageTimerExpiresAt,
|
||||
$received_at: received_at,
|
||||
$schemaVersion: schemaVersion,
|
||||
$sent_at: sent_at,
|
||||
|
@ -1517,7 +1610,9 @@ async function saveMessage(data, { forceSave } = {}) {
|
|||
if (id && !forceSave) {
|
||||
await db.run(
|
||||
`UPDATE messages SET
|
||||
id = $id,
|
||||
json = $json,
|
||||
|
||||
body = $body,
|
||||
conversationId = $conversationId,
|
||||
expirationStartTimestamp = $expirationStartTimestamp,
|
||||
|
@ -1526,7 +1621,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
|||
hasAttachments = $hasAttachments,
|
||||
hasFileAttachments = $hasFileAttachments,
|
||||
hasVisualMediaAttachments = $hasVisualMediaAttachments,
|
||||
id = $id,
|
||||
isErased = $isErased,
|
||||
messageTimer = $messageTimer,
|
||||
messageTimerStart = $messageTimerStart,
|
||||
messageTimerExpiresAt = $messageTimerExpiresAt,
|
||||
received_at = $received_at,
|
||||
schemaVersion = $schemaVersion,
|
||||
sent_at = $sent_at,
|
||||
|
@ -1559,6 +1657,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
|||
hasAttachments,
|
||||
hasFileAttachments,
|
||||
hasVisualMediaAttachments,
|
||||
isErased,
|
||||
messageTimer,
|
||||
messageTimerStart,
|
||||
messageTimerExpiresAt,
|
||||
received_at,
|
||||
schemaVersion,
|
||||
sent_at,
|
||||
|
@ -1578,6 +1680,10 @@ async function saveMessage(data, { forceSave } = {}) {
|
|||
$hasAttachments,
|
||||
$hasFileAttachments,
|
||||
$hasVisualMediaAttachments,
|
||||
$isErased,
|
||||
$messageTimer,
|
||||
$messageTimerStart,
|
||||
$messageTimerExpiresAt,
|
||||
$received_at,
|
||||
$schemaVersion,
|
||||
$sent_at,
|
||||
|
@ -1756,6 +1862,69 @@ async function getNextExpiringMessage() {
|
|||
return map(rows, row => jsonToObject(row.json));
|
||||
}
|
||||
|
||||
async function getNextTapToViewMessageToExpire() {
|
||||
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
|
||||
const rows = await db.all(`
|
||||
SELECT json FROM messages
|
||||
WHERE
|
||||
messageTimer > 0
|
||||
AND messageTimerExpiresAt > 0
|
||||
AND (isErased IS NULL OR isErased != 1)
|
||||
ORDER BY messageTimerExpiresAt ASC
|
||||
LIMIT 1;
|
||||
`);
|
||||
|
||||
if (!rows || rows.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jsonToObject(rows[0].json);
|
||||
}
|
||||
|
||||
async function getNextTapToViewMessageToAgeOut() {
|
||||
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
|
||||
const rows = await db.all(`
|
||||
SELECT json FROM messages
|
||||
WHERE
|
||||
messageTimer > 0
|
||||
AND (isErased IS NULL OR isErased != 1)
|
||||
ORDER BY received_at ASC
|
||||
LIMIT 1;
|
||||
`);
|
||||
|
||||
if (!rows || rows.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jsonToObject(rows[0].json);
|
||||
}
|
||||
|
||||
async function getTapToViewMessagesNeedingErase() {
|
||||
const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const NOW = Date.now();
|
||||
|
||||
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
|
||||
const rows = await db.all(
|
||||
`SELECT json FROM messages
|
||||
WHERE
|
||||
messageTimer > 0
|
||||
AND (isErased IS NULL OR isErased != 1)
|
||||
AND (
|
||||
(messageTimerExpiresAt > 0
|
||||
AND messageTimerExpiresAt <= $NOW)
|
||||
OR
|
||||
received_at <= $THIRTY_DAYS_AGO
|
||||
)
|
||||
ORDER BY received_at ASC;`,
|
||||
{
|
||||
$NOW: NOW,
|
||||
$THIRTY_DAYS_AGO: THIRTY_DAYS_AGO,
|
||||
}
|
||||
);
|
||||
|
||||
return map(rows, row => jsonToObject(row.json));
|
||||
}
|
||||
|
||||
async function saveUnprocessed(data, { forceSave } = {}) {
|
||||
const { id, timestamp, version, attempts, envelope } = data;
|
||||
if (!id) {
|
||||
|
|
|
@ -482,11 +482,13 @@
|
|||
<script type='text/javascript' src='js/delivery_receipts.js'></script>
|
||||
<script type='text/javascript' src='js/read_receipts.js'></script>
|
||||
<script type='text/javascript' src='js/read_syncs.js'></script>
|
||||
<script type='text/javascript' src='js/view_syncs.js'></script>
|
||||
<script type='text/javascript' src='js/libphonenumber-util.js'></script>
|
||||
<script type='text/javascript' src='js/models/messages.js'></script>
|
||||
<script type='text/javascript' src='js/models/conversations.js'></script>
|
||||
<script type='text/javascript' src='js/models/blockedNumbers.js'></script>
|
||||
<script type='text/javascript' src='js/expiring_messages.js'></script>
|
||||
<script type='text/javascript' src='js/expiring_tap_to_view_messages.js'></script>
|
||||
|
||||
<script type='text/javascript' src='js/chromium.js'></script>
|
||||
<script type='text/javascript' src='js/registration.js'></script>
|
||||
|
|
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.ExpiringMessagesListener.init(Whisper.events);
|
||||
Whisper.TapToViewMessagesListener.init(Whisper.events);
|
||||
|
||||
if (Whisper.Import.isIncomplete()) {
|
||||
window.log.info('Import was interrupted, showing import error screen');
|
||||
|
@ -836,6 +837,7 @@
|
|||
addQueuedEventListener('configuration', onConfiguration);
|
||||
addQueuedEventListener('typing', onTyping);
|
||||
addQueuedEventListener('sticker-pack', onStickerPack);
|
||||
addQueuedEventListener('viewSync', onViewSync);
|
||||
|
||||
window.Signal.AttachmentDownloads.start({
|
||||
getMessageReceiver: () => messageReceiver,
|
||||
|
@ -1685,6 +1687,22 @@
|
|||
throw error;
|
||||
}
|
||||
|
||||
async function onViewSync(ev) {
|
||||
const { viewedAt, source, timestamp } = ev;
|
||||
window.log.info(`view sync ${source} ${timestamp}, viewed at ${viewedAt}`);
|
||||
|
||||
const sync = Whisper.ViewSyncs.add({
|
||||
source,
|
||||
timestamp,
|
||||
viewedAt,
|
||||
});
|
||||
|
||||
sync.on('remove', ev.confirm);
|
||||
|
||||
// Calling this directly so we can wait for completion
|
||||
return Whisper.ViewSyncs.onSync(sync);
|
||||
}
|
||||
|
||||
function onReadReceipt(ev) {
|
||||
const readAt = ev.timestamp;
|
||||
const { timestamp } = ev.read;
|
||||
|
|
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,
|
||||
id: quotedMessage.get('sent_at'),
|
||||
text: body || embeddedContactName,
|
||||
attachments: await this.getQuoteAttachment(
|
||||
attachments,
|
||||
preview,
|
||||
sticker
|
||||
),
|
||||
attachments: quotedMessage.isTapToView()
|
||||
? [{ contentType: 'image/jpeg', fileName: null }]
|
||||
: await this.getQuoteAttachment(attachments, preview, sticker),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -470,6 +470,8 @@
|
|||
const isGroup = conversation && !conversation.isPrivate();
|
||||
const sticker = this.get('sticker');
|
||||
|
||||
const isTapToView = this.isTapToView();
|
||||
|
||||
return {
|
||||
text: this.createNonBreakingLastSeparator(this.get('body')),
|
||||
textPending: this.get('bodyPending'),
|
||||
|
@ -492,6 +494,12 @@
|
|||
expirationLength,
|
||||
expirationTimestamp,
|
||||
|
||||
isTapToView,
|
||||
isTapToViewExpired:
|
||||
isTapToView && (this.get('isErased') || this.isTapToViewExpired()),
|
||||
isTapToViewError:
|
||||
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
|
||||
|
||||
replyToMessage: id => this.trigger('reply', id),
|
||||
retrySend: id => this.trigger('retry', id),
|
||||
deleteMessage: id => this.trigger('delete', id),
|
||||
|
@ -506,6 +514,8 @@
|
|||
this.trigger('show-lightbox', lightboxOptions),
|
||||
downloadAttachment: downloadOptions =>
|
||||
this.trigger('download', downloadOptions),
|
||||
displayTapToViewMessage: messageId =>
|
||||
this.trigger('display-tap-to-view-message', messageId),
|
||||
|
||||
openLink: url => this.trigger('navigate-to', url),
|
||||
downloadNewVersion: () => this.trigger('download-new-version'),
|
||||
|
@ -727,6 +737,9 @@
|
|||
if (this.isUnsupportedMessage()) {
|
||||
return i18n('message--getDescription--unsupported-message');
|
||||
}
|
||||
if (this.isTapToView()) {
|
||||
return i18n('message--getDescription--disappearing-photo');
|
||||
}
|
||||
if (this.isGroupUpdate()) {
|
||||
const groupUpdate = this.get('group_update');
|
||||
if (groupUpdate.left === 'You') {
|
||||
|
@ -841,6 +854,9 @@
|
|||
async cleanup() {
|
||||
MessageController.unregister(this.id);
|
||||
this.unload();
|
||||
await this.deleteData();
|
||||
},
|
||||
async deleteData() {
|
||||
await deleteExternalMessageFiles(this.attributes);
|
||||
|
||||
const sticker = this.get('sticker');
|
||||
|
@ -853,6 +869,154 @@
|
|||
await deletePackReference(this.id, packId);
|
||||
}
|
||||
},
|
||||
isTapToView() {
|
||||
return Boolean(this.get('messageTimer'));
|
||||
},
|
||||
isValidTapToView() {
|
||||
const body = this.get('body');
|
||||
if (body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachments = this.get('attachments');
|
||||
if (!attachments || attachments.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstAttachment = attachments[0];
|
||||
if (
|
||||
!window.Signal.Util.GoogleChrome.isImageTypeSupported(
|
||||
firstAttachment.contentType
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quote = this.get('quote');
|
||||
const sticker = this.get('sticker');
|
||||
const contact = this.get('contact');
|
||||
const preview = this.get('preview');
|
||||
|
||||
if (
|
||||
quote ||
|
||||
sticker ||
|
||||
(contact && contact.length > 0) ||
|
||||
(preview && preview.length > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
isTapToViewExpired() {
|
||||
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
const receivedAt = this.get('received_at');
|
||||
if (now >= receivedAt + THIRTY_DAYS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const messageTimer = this.get('messageTimer');
|
||||
const messageTimerStart = this.get('messageTimerStart');
|
||||
if (!messageTimerStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = messageTimerStart + messageTimer * 1000;
|
||||
if (now >= expiresAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async startTapToViewTimer(viewedAt, options) {
|
||||
const { fromSync } = options || {};
|
||||
|
||||
if (this.get('unread')) {
|
||||
await this.markRead();
|
||||
}
|
||||
|
||||
const messageTimer = this.get('messageTimer');
|
||||
if (!messageTimer) {
|
||||
window.log.warn(
|
||||
`startTapToViewTimer: Message ${this.idForLogging()} has no messageTimer!`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimerStart = this.get('messageTimerStart');
|
||||
const messageTimerStart = Math.min(
|
||||
Date.now(),
|
||||
viewedAt || Date.now(),
|
||||
existingTimerStart || Date.now()
|
||||
);
|
||||
const messageTimerExpiresAt = messageTimerStart + messageTimer * 1000;
|
||||
|
||||
// Because we're not using Backbone-integrated saves, we need to manually
|
||||
// clear the changed fields here so our hasChanged() check below is useful.
|
||||
this.changed = {};
|
||||
this.set({
|
||||
messageTimerStart,
|
||||
messageTimerExpiresAt,
|
||||
});
|
||||
|
||||
if (!this.hasChanged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
|
||||
if (!fromSync) {
|
||||
const sender = this.getSource();
|
||||
const timestamp = this.get('sent_at');
|
||||
const ourNumber = textsecure.storage.user.getNumber();
|
||||
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
||||
ourNumber,
|
||||
{ syncMessage: true }
|
||||
);
|
||||
|
||||
await wrap(
|
||||
textsecure.messaging.syncMessageTimerRead(
|
||||
sender,
|
||||
timestamp,
|
||||
sendOptions
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
async eraseContents() {
|
||||
if (this.get('isErased')) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.log.info(`Erasing data for message ${this.idForLogging()}`);
|
||||
|
||||
try {
|
||||
await this.deleteData();
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`Error erasing data for message ${this.idForLogging()}:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
|
||||
this.set({
|
||||
isErased: true,
|
||||
body: '',
|
||||
attachments: [],
|
||||
quote: null,
|
||||
contact: [],
|
||||
sticker: null,
|
||||
preview: [],
|
||||
});
|
||||
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
},
|
||||
unload() {
|
||||
if (this.quotedMessage) {
|
||||
this.quotedMessage = null;
|
||||
|
@ -1581,6 +1745,16 @@
|
|||
quote.referencedMessageNotFound = true;
|
||||
return message;
|
||||
}
|
||||
if (found.isTapToView()) {
|
||||
quote.text = null;
|
||||
quote.attachments = [
|
||||
{
|
||||
contentType: 'image/jpeg',
|
||||
},
|
||||
];
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
const queryMessage = MessageController.register(found.id, found);
|
||||
quote.text = queryMessage.get('body');
|
||||
|
@ -1765,6 +1939,7 @@
|
|||
hasAttachments: dataMessage.hasAttachments,
|
||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||
messageTimer: dataMessage.messageTimer,
|
||||
preview,
|
||||
requiredProtocolVersion:
|
||||
dataMessage.requiredProtocolVersion ||
|
||||
|
@ -1925,7 +2100,34 @@
|
|||
message.set({ id });
|
||||
MessageController.register(message.id, message);
|
||||
|
||||
if (!message.isUnsupportedMessage()) {
|
||||
if (message.isTapToView() && type === 'outgoing') {
|
||||
await message.eraseContents();
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'incoming' &&
|
||||
message.isTapToView() &&
|
||||
!message.isValidTapToView()
|
||||
) {
|
||||
window.log.warn(
|
||||
`Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.`
|
||||
);
|
||||
message.set({
|
||||
isTapToViewInvalid: true,
|
||||
});
|
||||
await message.eraseContents();
|
||||
}
|
||||
// Check for out-of-order view syncs
|
||||
if (type === 'incoming' && message.isTapToView()) {
|
||||
const viewSync = Whisper.ViewSyncs.forMessage(message);
|
||||
if (viewSync) {
|
||||
await Whisper.ViewSyncs.onSync(viewSync);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.isUnsupportedMessage()) {
|
||||
await message.eraseContents();
|
||||
} else {
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
|
@ -2017,8 +2219,10 @@
|
|||
};
|
||||
};
|
||||
|
||||
Whisper.Message.refreshExpirationTimer = () =>
|
||||
Whisper.Message.updateTimers = () => {
|
||||
Whisper.ExpiringMessagesListener.update();
|
||||
Whisper.TapToViewMessagesListener.update();
|
||||
};
|
||||
|
||||
Whisper.MessageCollection = Backbone.Collection.extend({
|
||||
model: Whisper.Message,
|
||||
|
|
|
@ -715,7 +715,7 @@ async function exportConversation(conversation, options = {}) {
|
|||
count += 1;
|
||||
|
||||
// skip message if it is disappearing, no matter the amount of time left
|
||||
if (message.expireTimer) {
|
||||
if (message.expireTimer || message.messageTimer) {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
|
|
@ -122,6 +122,9 @@ module.exports = {
|
|||
getOutgoingWithoutExpiresAt,
|
||||
getNextExpiringMessage,
|
||||
getMessagesByConversation,
|
||||
getNextTapToViewMessageToExpire,
|
||||
getNextTapToViewMessageToAgeOut,
|
||||
getTapToViewMessagesNeedingErase,
|
||||
|
||||
getUnprocessedCount,
|
||||
getAllUnprocessed,
|
||||
|
@ -674,7 +677,7 @@ async function getMessageCount() {
|
|||
|
||||
async function saveMessage(data, { forceSave, Message } = {}) {
|
||||
const id = await channels.saveMessage(_cleanData(data), { forceSave });
|
||||
Message.refreshExpirationTimer();
|
||||
Message.updateTimers();
|
||||
return id;
|
||||
}
|
||||
|
||||
|
@ -839,6 +842,27 @@ async function getNextExpiringMessage({ MessageCollection }) {
|
|||
return new MessageCollection(messages);
|
||||
}
|
||||
|
||||
async function getNextTapToViewMessageToExpire({ Message }) {
|
||||
const message = await channels.getNextTapToViewMessageToExpire();
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Message(message);
|
||||
}
|
||||
async function getNextTapToViewMessageToAgeOut({ Message }) {
|
||||
const message = await channels.getNextTapToViewMessageToAgeOut();
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Message(message);
|
||||
}
|
||||
async function getTapToViewMessagesNeedingErase({ MessageCollection }) {
|
||||
const messages = await channels.getTapToViewMessagesNeedingErase();
|
||||
return new MessageCollection(messages);
|
||||
}
|
||||
|
||||
// Unprocessed
|
||||
|
||||
async function getUnprocessedCount() {
|
||||
|
|
67
js/view_syncs.js
Normal file
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',
|
||||
this.downloadAttachment
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'display-tap-to-view-message',
|
||||
this.displayTapToViewMessage
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'open-conversation',
|
||||
|
@ -461,8 +466,8 @@
|
|||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
}
|
||||
if (this.lightBoxView) {
|
||||
this.lightBoxView.remove();
|
||||
if (this.lightboxView) {
|
||||
this.lightboxView.remove();
|
||||
}
|
||||
if (this.lightboxGalleryView) {
|
||||
this.lightboxGalleryView.remove();
|
||||
|
@ -1344,6 +1349,66 @@
|
|||
});
|
||||
},
|
||||
|
||||
async displayTapToViewMessage(messageId) {
|
||||
const message = this.model.messageCollection.get(messageId);
|
||||
if (!message) {
|
||||
throw new Error(
|
||||
`displayTapToViewMessage: Did not find message for id ${messageId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!message.isTapToView()) {
|
||||
throw new Error(
|
||||
`displayTapToViewMessage: Message ${message.idForLogging()} is not tap to view`
|
||||
);
|
||||
}
|
||||
|
||||
if (message.isTapToViewExpired()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await message.startTapToViewTimer();
|
||||
|
||||
const closeLightbox = () => {
|
||||
if (!this.lightboxView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lightboxView } = this;
|
||||
this.lightboxView = null;
|
||||
|
||||
this.stopListening(message);
|
||||
Signal.Backbone.Views.Lightbox.hide();
|
||||
lightboxView.remove();
|
||||
};
|
||||
this.listenTo(message, 'expired', closeLightbox);
|
||||
this.listenTo(message, 'change', () => {
|
||||
if (this.lightBoxView) {
|
||||
this.lightBoxView.update(getProps());
|
||||
}
|
||||
});
|
||||
|
||||
const getProps = () => {
|
||||
const firstAttachment = message.get('attachments')[0];
|
||||
const { path, contentType } = firstAttachment;
|
||||
|
||||
return {
|
||||
objectURL: getAbsoluteAttachmentPath(path),
|
||||
contentType,
|
||||
timerExpiresAt: message.get('messageTimerExpiresAt'),
|
||||
timerDuration: message.get('messageTimer') * 1000,
|
||||
};
|
||||
};
|
||||
this.lightboxView = new Whisper.ReactWrapperView({
|
||||
className: 'lightbox-wrapper',
|
||||
Component: Signal.Components.Lightbox,
|
||||
props: getProps(),
|
||||
onClose: closeLightbox,
|
||||
});
|
||||
|
||||
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
|
||||
},
|
||||
|
||||
deleteMessage(messageId) {
|
||||
const message = this.model.messageCollection.get(messageId);
|
||||
if (!message) {
|
||||
|
|
|
@ -1110,11 +1110,19 @@ MessageReceiver.prototype.extend({
|
|||
return this.handleVerified(envelope, syncMessage.verified);
|
||||
} else if (syncMessage.configuration) {
|
||||
return this.handleConfiguration(envelope, syncMessage.configuration);
|
||||
} else if (syncMessage.stickerPackOperation) {
|
||||
} else if (
|
||||
syncMessage.stickerPackOperation &&
|
||||
syncMessage.stickerPackOperation.length > 0
|
||||
) {
|
||||
return this.handleStickerPackOperation(
|
||||
envelope,
|
||||
syncMessage.stickerPackOperation
|
||||
);
|
||||
} else if (syncMessage.messageTimerRead) {
|
||||
return this.handleMessageTimerRead(
|
||||
envelope,
|
||||
syncMessage.messageTimerRead
|
||||
);
|
||||
}
|
||||
throw new Error('Got empty SyncMessage');
|
||||
},
|
||||
|
@ -1125,6 +1133,17 @@ MessageReceiver.prototype.extend({
|
|||
ev.configuration = configuration;
|
||||
return this.dispatchAndWait(ev);
|
||||
},
|
||||
handleMessageTimerRead(envelope, sync) {
|
||||
window.log.info('got message timer read sync message');
|
||||
|
||||
const ev = new Event('viewSync');
|
||||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||
ev.source = sync.sender;
|
||||
ev.timestamp = sync.timestamp ? sync.timestamp.toNumber() : null;
|
||||
ev.viewedAt = envelope.timestamp;
|
||||
|
||||
return this.dispatchAndWait(ev);
|
||||
},
|
||||
handleStickerPackOperation(envelope, operations) {
|
||||
const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type;
|
||||
window.log.info('got sticker pack operation sync message');
|
||||
|
|
|
@ -750,6 +750,34 @@ MessageSender.prototype = {
|
|||
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
async syncMessageTimerRead(sender, timestamp, options) {
|
||||
const myNumber = textsecure.storage.user.getNumber();
|
||||
const myDevice = textsecure.storage.user.getDeviceId();
|
||||
if (myDevice === 1 || myDevice === '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const syncMessage = this.createSyncMessage();
|
||||
|
||||
const messageTimerRead = new textsecure.protobuf.SyncMessage.MessageTimerRead();
|
||||
messageTimerRead.sender = sender;
|
||||
messageTimerRead.timestamp = timestamp;
|
||||
syncMessage.messageTimerRead = messageTimerRead;
|
||||
|
||||
const contentMessage = new textsecure.protobuf.Content();
|
||||
contentMessage.syncMessage = syncMessage;
|
||||
|
||||
const silent = true;
|
||||
return this.sendIndividualProto(
|
||||
myNumber,
|
||||
contentMessage,
|
||||
Date.now(),
|
||||
silent,
|
||||
options
|
||||
);
|
||||
},
|
||||
|
||||
async sendStickerPackSync(operations, options) {
|
||||
const myDevice = textsecure.storage.user.getDeviceId();
|
||||
if (myDevice === 1 || myDevice === '1') {
|
||||
|
@ -1238,6 +1266,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
|
|||
this.getSticker = sender.getSticker.bind(sender);
|
||||
this.getStickerPackManifest = sender.getStickerPackManifest.bind(sender);
|
||||
this.sendStickerPackSync = sender.sendStickerPackSync.bind(sender);
|
||||
this.syncMessageTimerRead = sender.syncMessageTimerRead.bind(sender);
|
||||
};
|
||||
|
||||
textsecure.MessageSender.prototype = {
|
||||
|
|
|
@ -173,7 +173,8 @@ message DataMessage {
|
|||
option allow_alias = true;
|
||||
|
||||
INITIAL = 0;
|
||||
CURRENT = 0;
|
||||
MESSAGE_TIMERS = 1;
|
||||
CURRENT = 1;
|
||||
}
|
||||
|
||||
optional string body = 1;
|
||||
|
@ -188,6 +189,7 @@ message DataMessage {
|
|||
repeated Preview preview = 10;
|
||||
optional Sticker sticker = 11;
|
||||
optional uint32 requiredProtocolVersion = 12;
|
||||
optional uint32 messageTimer = 13;
|
||||
}
|
||||
|
||||
message NullMessage {
|
||||
|
@ -291,6 +293,11 @@ message SyncMessage {
|
|||
optional Type type = 3;
|
||||
}
|
||||
|
||||
message MessageTimerRead {
|
||||
optional string sender = 1;
|
||||
optional uint64 timestamp = 2;
|
||||
}
|
||||
|
||||
optional Sent sent = 1;
|
||||
optional Contacts contacts = 2;
|
||||
optional Groups groups = 3;
|
||||
|
@ -301,6 +308,7 @@ message SyncMessage {
|
|||
optional Configuration configuration = 9;
|
||||
optional bytes padding = 8;
|
||||
repeated StickerPackOperation stickerPackOperation = 10;
|
||||
optional MessageTimerRead messageTimerRead = 11;
|
||||
}
|
||||
|
||||
message AttachmentPointer {
|
||||
|
|
|
@ -13,6 +13,48 @@
|
|||
color: $color-gray-90;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-expired {
|
||||
border: 1px solid $color-gray-15;
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-error {
|
||||
background-color: $color-white;
|
||||
border: 1px solid $color-deep-red;
|
||||
}
|
||||
|
||||
.module-message__tap-to-view__icon {
|
||||
background-color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__icon--outgoing {
|
||||
background-color: $color-white;
|
||||
}
|
||||
.module-message__tap-to-view__icon--expired {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
.module-message__tap-to-view__text {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__text--outgoing {
|
||||
color: $color-white;
|
||||
}
|
||||
.module-message__tap-to-view__text--outgoing-expired {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming-expired {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming-error {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-pending {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
|
||||
.module-message__author {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
|
@ -46,19 +88,22 @@
|
|||
.module-message__metadata__date--with-sticker {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
.module-message__metadata__date--outgoing-with-tap-to-view-expired {
|
||||
color: $color-gray-75;
|
||||
}
|
||||
|
||||
.module-message__metadata__status-icon--sending {
|
||||
@include color-svg('../images/sending.svg', $color-white);
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
.module-message__metadata__status-icon--sent {
|
||||
@include color-svg('../images/check-circle-outline.svg', $color-white-08);
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
.module-message__metadata__status-icon--delivered {
|
||||
@include color-svg('../images/double-check.svg', $color-white-08);
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
.module-message__metadata__status-icon--read {
|
||||
@include color-svg('../images/read.svg', $color-white-08);
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
|
||||
.module-message__metadata__status-icon--with-image-no-caption {
|
||||
|
@ -67,6 +112,9 @@
|
|||
.module-message__metadata__status-icon--with-sticker {
|
||||
background-color: $color-gray-60;
|
||||
}
|
||||
.module-message__metadata__status-icon--with-tap-to-view-expired {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
.module-message__generic-attachment__file-name {
|
||||
color: $color-white;
|
||||
|
@ -93,6 +141,9 @@
|
|||
.module-expire-timer--with-sticker {
|
||||
background-color: $color-gray-60;
|
||||
}
|
||||
.module-expire-timer--outgoing-with-tap-to-view-expired {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
.module-quote--outgoing {
|
||||
border-left-color: $color-white;
|
||||
|
@ -167,6 +218,16 @@
|
|||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-expired {
|
||||
border: 1px solid $color-gray-60;
|
||||
background-color: $color-black;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-error {
|
||||
background-color: $color-black;
|
||||
border: 1px solid $color-deep-red;
|
||||
}
|
||||
|
||||
.module-message__author {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
@ -180,17 +241,17 @@
|
|||
}
|
||||
|
||||
.module-message__metadata__status-icon--sending {
|
||||
@include color-svg('../images/sending.svg', $color-white);
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
.module-message__metadata__status-icon--sent {
|
||||
@include color-svg('../images/check-circle-outline.svg', $color-white-08);
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
.module-message__metadata__status-icon--delivered {
|
||||
@include color-svg('../images/double-check.svg', $color-white-08);
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
.module-message__metadata__status-icon--read {
|
||||
@include color-svg('../images/read.svg', $color-white-08);
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
|
||||
.module-message__metadata__date {
|
||||
|
|
|
@ -204,6 +204,121 @@
|
|||
background-color: $color-conversation-blue_grey;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view {
|
||||
min-width: 148px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-message__container--incoming--tap-to-view-pending {
|
||||
background-color: $color-conversation-grey-shade;
|
||||
}
|
||||
.module-message__container--incoming-red-tap-to-view-pending {
|
||||
background-color: $color-conversation-red-shade;
|
||||
}
|
||||
.module-message__container--incoming-deep_orange-tap-to-view-pending {
|
||||
background-color: $color-conversation-deep_orange-shade;
|
||||
}
|
||||
.module-message__container--incoming-brown-tap-to-view-pending {
|
||||
background-color: $color-conversation-brown-shade;
|
||||
}
|
||||
.module-message__container--incoming-pink-tap-to-view-pending {
|
||||
background-color: $color-conversation-pink-shade;
|
||||
}
|
||||
.module-message__container--incoming-purple-tap-to-view-pending {
|
||||
background-color: $color-conversation-purple-shade;
|
||||
}
|
||||
.module-message__container--incoming-indigo-tap-to-view-pending {
|
||||
background-color: $color-conversation-indigo-shade;
|
||||
}
|
||||
.module-message__container--incoming-blue-tap-to-view-pending {
|
||||
background-color: $color-conversation-blue-shade;
|
||||
}
|
||||
.module-message__container--incoming-teal-tap-to-view-pending {
|
||||
background-color: $color-conversation-teal-shade;
|
||||
}
|
||||
.module-message__container--incoming-green-tap-to-view-pending {
|
||||
background-color: $color-conversation-green-shade;
|
||||
}
|
||||
.module-message__container--incoming-light_green-tap-to-view-pending {
|
||||
background-color: $color-conversation-light_green-shade;
|
||||
}
|
||||
.module-message__container--incoming-blue_grey-tap-to-view-pending {
|
||||
background-color: $color-conversation-blue_grey-shade;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-pending {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-expired {
|
||||
cursor: default;
|
||||
border: 1px solid $color-gray-15;
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-error {
|
||||
background-color: $color-white;
|
||||
border: 1px solid $color-core-red;
|
||||
width: auto;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.module-message__tap-to-view {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.module-message__tap-to-view--with-content-above {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.module-message__tap-to-view--with-content-below {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.module-message__tap-to-view__spinner-container {
|
||||
margin-right: 6px;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.module-message__tap-to-view__icon {
|
||||
margin-right: 6px;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
@include color-svg('../images/play-filled-24.svg', $color-white);
|
||||
}
|
||||
.module-message__tap-to-view__icon--outgoing {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
.module-message__tap-to-view__icon--expired {
|
||||
@include color-svg('../images/play-outline-24.svg', $color-gray-75);
|
||||
}
|
||||
.module-message__tap-to-view__text {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 18px;
|
||||
|
||||
color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming {
|
||||
color: $color-white;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming-expired {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming-error {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
|
||||
.module-message__attachment-container {
|
||||
// To ensure that images are centered if they aren't full width of bubble
|
||||
text-align: center;
|
||||
|
@ -472,6 +587,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
.module-message__author--with-tap-to-view-expired {
|
||||
color: $color-gray-75;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&__profile-name {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.module-message__author_with_sticker {
|
||||
color: $color-gray-90;
|
||||
font-size: 13px;
|
||||
|
@ -555,6 +686,9 @@
|
|||
.module-message__metadata__date--with-image-no-caption {
|
||||
color: $color-white;
|
||||
}
|
||||
.module-message__metadata__date--incoming-with-tap-to-view-expired {
|
||||
color: $color-gray-75;
|
||||
}
|
||||
|
||||
.module-message__metadata__spacer {
|
||||
flex-grow: 1;
|
||||
|
@ -683,6 +817,9 @@
|
|||
.module-expire-timer--incoming {
|
||||
background-color: $color-white-08;
|
||||
}
|
||||
.module-expire-timer--incoming-with-tap-to-view-expired {
|
||||
background-color: $color-gray-75;
|
||||
}
|
||||
|
||||
// When status indicators are overlaid on top of an image, they use different colors
|
||||
.module-expire-timer--with-image-no-caption {
|
||||
|
@ -2813,8 +2950,8 @@
|
|||
|
||||
@include color-svg('../images/spinner-track-56.svg', $color-white-04);
|
||||
z-index: 2;
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.module-spinner__arc {
|
||||
position: absolute;
|
||||
|
@ -2823,8 +2960,8 @@
|
|||
|
||||
@include color-svg('../images/spinner-56.svg', $color-gray-60);
|
||||
z-index: 3;
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
animation: spinner-arc-animation 1000ms linear infinite;
|
||||
}
|
||||
|
@ -2844,38 +2981,13 @@
|
|||
// In these --small and --mini sizes, we're exploding our @color-svg mixin so we don't
|
||||
// have to duplicate our background colors for the dark/ios/size matrix.
|
||||
|
||||
.module-spinner__container--small {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
.module-spinner__circle--small {
|
||||
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
|
||||
-webkit-mask-size: 100%;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
.module-spinner__arc--small {
|
||||
-webkit-mask: url('../images/spinner-24.svg') no-repeat center;
|
||||
-webkit-mask-size: 100%;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.module-spinner__container--mini {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
.module-spinner__circle--mini {
|
||||
-webkit-mask: url('../images/spinner-track-24.svg') no-repeat center;
|
||||
-webkit-mask-size: 100%;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
.module-spinner__arc--mini {
|
||||
-webkit-mask: url('../images/spinner-24.svg') no-repeat center;
|
||||
-webkit-mask-size: 100%;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.module-spinner__circle--incoming {
|
||||
|
@ -4524,6 +4636,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Module: Countdown
|
||||
|
||||
.module-countdown {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.module-countdown__path {
|
||||
fill-opacity: 0;
|
||||
stroke: $color-white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
// Third-party module: react-contextmenu
|
||||
|
||||
.react-contextmenu {
|
||||
|
|
|
@ -579,6 +579,75 @@ body.dark-theme {
|
|||
background-color: $color-conversation-blue_grey;
|
||||
}
|
||||
|
||||
.module-message__container--incoming--tap-to-view-pending {
|
||||
background-color: $color-conversation-grey-shade;
|
||||
}
|
||||
.module-message__container--incoming-red-tap-to-view-pending {
|
||||
background-color: $color-conversation-red-shade;
|
||||
}
|
||||
.module-message__container--incoming-deep_orange-tap-to-view-pending {
|
||||
background-color: $color-conversation-deep_orange-shade;
|
||||
}
|
||||
.module-message__container--incoming-brown-tap-to-view-pending {
|
||||
background-color: $color-conversation-brown-shade;
|
||||
}
|
||||
.module-message__container--incoming-pink-tap-to-view-pending {
|
||||
background-color: $color-conversation-pink-shade;
|
||||
}
|
||||
.module-message__container--incoming-purple-tap-to-view-pending {
|
||||
background-color: $color-conversation-purple-shade;
|
||||
}
|
||||
.module-message__container--incoming-indigo-tap-to-view-pending {
|
||||
background-color: $color-conversation-indigo-shade;
|
||||
}
|
||||
.module-message__container--incoming-blue-tap-to-view-pending {
|
||||
background-color: $color-conversation-blue-shade;
|
||||
}
|
||||
.module-message__container--incoming-teal-tap-to-view-pending {
|
||||
background-color: $color-conversation-teal-shade;
|
||||
}
|
||||
.module-message__container--incoming-green-tap-to-view-pending {
|
||||
background-color: $color-conversation-green-shade;
|
||||
}
|
||||
.module-message__container--incoming-light_green-tap-to-view-pending {
|
||||
background-color: $color-conversation-light_green-shade;
|
||||
}
|
||||
.module-message__container--incoming-blue_grey-tap-to-view-pending {
|
||||
background-color: $color-conversation-blue_grey-shade;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-expired {
|
||||
border: 1px solid $color-gray-60;
|
||||
background-color: $color-black;
|
||||
}
|
||||
|
||||
.module-message__container--with-tap-to-view-error {
|
||||
background-color: $color-gray-95;
|
||||
border: 1px solid $color-deep-red;
|
||||
}
|
||||
|
||||
.module-message__tap-to-view__icon {
|
||||
background-color: $color-gray-05;
|
||||
}
|
||||
.module-message__tap-to-view__icon--outgoing {
|
||||
background-color: $color-gray-05;
|
||||
}
|
||||
.module-message__tap-to-view__icon--expired {
|
||||
background-color: $color-gray-05;
|
||||
}
|
||||
.module-message__tap-to-view__text {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming-expired {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
.module-message__tap-to-view__text--incoming-error {
|
||||
color: $color-gray-25;
|
||||
}
|
||||
|
||||
.module-message__attachment-container {
|
||||
background-color: $color-gray-95;
|
||||
}
|
||||
|
@ -674,6 +743,10 @@ body.dark-theme {
|
|||
color: $color-white;
|
||||
}
|
||||
|
||||
.module-message__author--with-tap-to-view-expired {
|
||||
color: $color-white;
|
||||
}
|
||||
|
||||
.module-message__author_with_sticker {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ $roboto-light: Roboto-Light, 'Helvetica Neue', 'Source Sans Pro Light',
|
|||
$color-signal-blue: #2090ea;
|
||||
$color-core-green: #4caf50;
|
||||
$color-core-red: #f44336;
|
||||
$color-deep-red: #ff261f;
|
||||
|
||||
$color-signal-blue-025: rgba($color-signal-blue, 0.25);
|
||||
$color-signal-blue-050: rgba($color-signal-blue, 0.5);
|
||||
|
|
|
@ -474,6 +474,7 @@
|
|||
<script type="text/javascript" src="../js/message_controller.js" data-cover></script>
|
||||
<script type="text/javascript" src="../js/keychange_listener.js" data-cover></script>
|
||||
<script type='text/javascript' src='../js/expiring_messages.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/expiring_tap_to_view_messages.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/notifications.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/focus_listener.js'></script>
|
||||
|
||||
|
|
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
|
||||
|
||||
```js
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||
|
@ -15,7 +15,7 @@ const noop = () => {};
|
|||
|
||||
## Image with caption
|
||||
|
||||
```js
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||
|
@ -29,9 +29,27 @@ const noop = () => {};
|
|||
</div>;
|
||||
```
|
||||
|
||||
## Image with timer
|
||||
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||
<Lightbox
|
||||
objectURL="https://placekitten.com/800/600"
|
||||
contentType="image/jpeg"
|
||||
timerExpiresAt={Date.now() + 10 * 1000}
|
||||
timerDuration={30 * 1000}
|
||||
onSave={null}
|
||||
close={() => console.log('close')}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>;
|
||||
```
|
||||
|
||||
## Image (unsupported format)
|
||||
|
||||
```js
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||
|
@ -46,7 +64,7 @@ const noop = () => {};
|
|||
|
||||
## Video (supported format)
|
||||
|
||||
```js
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||
|
@ -61,7 +79,7 @@ const noop = () => {};
|
|||
|
||||
## Video (unsupported format)
|
||||
|
||||
```js
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 500 }}>
|
||||
|
@ -76,7 +94,7 @@ const noop = () => {};
|
|||
|
||||
## Unsupported file format
|
||||
|
||||
```js
|
||||
```jsx
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{ position: 'relative', width: '100%', height: 600 }}>
|
||||
|
|
|
@ -104,6 +104,9 @@ const styles = {
|
|||
saveButton: {
|
||||
marginTop: 10,
|
||||
},
|
||||
countdownContainer: {
|
||||
padding: 8,
|
||||
},
|
||||
iconButtonPlaceholder: {
|
||||
// Dimensions match `.iconButton`:
|
||||
display: 'inline-block',
|
||||
|
@ -211,11 +214,11 @@ export class Lightbox extends React.Component<Props> {
|
|||
const {
|
||||
caption,
|
||||
contentType,
|
||||
i18n,
|
||||
objectURL,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSave,
|
||||
i18n,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,32 +1,47 @@
|
|||
#### Large
|
||||
#### Normal, no size
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Spinner size="normal" />
|
||||
<Spinner svgSize="normal" />
|
||||
<div style={{ backgroundColor: '#2090ea' }}>
|
||||
<Spinner size="normal" />
|
||||
<Spinner svgSize="normal" />
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Small
|
||||
#### Normal, with size
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Spinner size="small" />
|
||||
<Spinner svgSize="normal" size="100px" />
|
||||
<div style={{ backgroundColor: '#2090ea' }}>
|
||||
<Spinner size="small" />
|
||||
<Spinner svgSize="normal" size="100px" />
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Mini
|
||||
#### Small, no size
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Spinner size="mini" />
|
||||
<Spinner svgSize="small" />
|
||||
<div style={{ backgroundColor: '#2090ea' }}>
|
||||
<Spinner size="mini" />
|
||||
<Spinner svgSize="small" />
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Small, sizes
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Spinner svgSize="small" size="20px" />
|
||||
<div style={{ backgroundColor: '#2090ea' }}>
|
||||
<Spinner svgSize="small" size="20px" />
|
||||
</div>
|
||||
<Spinner svgSize="small" size="14px" />
|
||||
<div style={{ backgroundColor: '#2090ea' }}>
|
||||
<Spinner svgSize="small" size="14px" />
|
||||
</div>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
|
|
@ -2,37 +2,44 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
size: 'small' | 'mini' | 'normal';
|
||||
size?: string;
|
||||
svgSize: 'small' | 'normal';
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
export class Spinner extends React.Component<Props> {
|
||||
public render() {
|
||||
const { size, direction } = this.props;
|
||||
const { size, svgSize, direction } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-spinner__container',
|
||||
`module-spinner__container--${size}`,
|
||||
`module-spinner__container--${svgSize}`,
|
||||
direction ? `module-spinner__container--${direction}` : null,
|
||||
direction ? `module-spinner__container--${size}-${direction}` : null
|
||||
direction
|
||||
? `module-spinner__container--${svgSize}-${direction}`
|
||||
: null
|
||||
)}
|
||||
style={{
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-spinner__circle',
|
||||
`module-spinner__circle--${size}`,
|
||||
`module-spinner__circle--${svgSize}`,
|
||||
direction ? `module-spinner__circle--${direction}` : null,
|
||||
direction ? `module-spinner__circle--${size}-${direction}` : null
|
||||
direction ? `module-spinner__circle--${svgSize}-${direction}` : null
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-spinner__arc',
|
||||
`module-spinner__arc--${size}`,
|
||||
`module-spinner__arc--${svgSize}`,
|
||||
direction ? `module-spinner__arc--${direction}` : null,
|
||||
direction ? `module-spinner__arc--${size}-${direction}` : null
|
||||
direction ? `module-spinner__arc--${svgSize}-${direction}` : null
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -199,6 +199,42 @@ const contact = {
|
|||
/>;
|
||||
```
|
||||
|
||||
### With all data types
|
||||
|
||||
```jsx
|
||||
const contact = {
|
||||
avatar: {
|
||||
avatar: {
|
||||
pending: true,
|
||||
},
|
||||
},
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 3,
|
||||
},
|
||||
],
|
||||
address: [
|
||||
{
|
||||
street: '5 Pike Place',
|
||||
city: 'Seattle',
|
||||
region: 'WA',
|
||||
postcode: '98101',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
<ContactDetail
|
||||
contact={contact}
|
||||
hasSignalAccount={true}
|
||||
i18n={util.i18n}
|
||||
onSendMessage={() => console.log('onSendMessage')}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Empty contact
|
||||
|
||||
```jsx
|
||||
|
|
|
@ -4,8 +4,9 @@ import classNames from 'classnames';
|
|||
import { getIncrement, getTimerBucket } from '../../util/timer';
|
||||
|
||||
interface Props {
|
||||
withImageNoCaption: boolean;
|
||||
withSticker: boolean;
|
||||
withImageNoCaption?: boolean;
|
||||
withSticker?: boolean;
|
||||
withTapToViewExpired?: boolean;
|
||||
expirationLength: number;
|
||||
expirationTimestamp: number;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
|
@ -46,6 +47,7 @@ export class ExpireTimer extends React.Component<Props> {
|
|||
expirationTimestamp,
|
||||
withImageNoCaption,
|
||||
withSticker,
|
||||
withTapToViewExpired,
|
||||
} = this.props;
|
||||
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
|
@ -55,8 +57,11 @@ export class ExpireTimer extends React.Component<Props> {
|
|||
className={classNames(
|
||||
'module-expire-timer',
|
||||
`module-expire-timer--${bucket}`,
|
||||
`module-expire-timer--${direction}`,
|
||||
withImageNoCaption
|
||||
direction ? `module-expire-timer--${direction}` : null,
|
||||
withTapToViewExpired
|
||||
? `module-expire-timer--${direction}-with-tap-to-view-expired`
|
||||
: null,
|
||||
direction && withImageNoCaption
|
||||
? 'module-expire-timer--with-image-no-caption'
|
||||
: null,
|
||||
withSticker ? 'module-expire-timer--with-sticker' : null
|
||||
|
|
|
@ -99,7 +99,7 @@ export class Image extends React.Component<Props> {
|
|||
}}
|
||||
// alt={i18n('loading')}
|
||||
>
|
||||
<Spinner size="normal" />
|
||||
<Spinner svgSize="normal" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
|
|
|
@ -3565,6 +3565,440 @@ Sticker link previews are forced to use the small link preview form, no matter t
|
|||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Tap to view
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
authorColor="pink"
|
||||
conversationType="direct"
|
||||
authorPhoneNumber="(202) 555-0003"
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId1"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
authorColor="blue"
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
conversationType="direct"
|
||||
i18n={util.i18n}
|
||||
id="messageId2"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
authorColor="green"
|
||||
conversationType="group"
|
||||
authorPhoneNumber="(202) 555-0003"
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId3"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
conversationType="group"
|
||||
authorPhoneNumber="(202) 555-0003"
|
||||
authorColor="blue"
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
conversationType="group"
|
||||
i18n={util.i18n}
|
||||
id="messageId4"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
conversationType="group"
|
||||
authorPhoneNumber="(202) 555-0003"
|
||||
authorProfileName="A very long profile name which cannot be shown in its entirety, or maybe it can!"
|
||||
authorColor="blue"
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
conversationType="group"
|
||||
i18n={util.i18n}
|
||||
id="messageId4"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
collapseMetadata={true}
|
||||
authorColor="blue"
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
conversationType="direct"
|
||||
i18n={util.i18n}
|
||||
id="messageId5"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
timestamp={Date.now()}
|
||||
conversationType="group"
|
||||
authorName="Not shown"
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
authorColor="green"
|
||||
status="read"
|
||||
collapseMetadata={true}
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId8"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
timestamp={Date.now()}
|
||||
conversationType="group"
|
||||
authorName="Not shown"
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
authorColor="green"
|
||||
status="read"
|
||||
collapseMetadata={true}
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId8"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
authorColor="green"
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
expirationLength={5 * 60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 60 * 1000}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId3"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
timestamp={Date.now()}
|
||||
authorColor="blue"
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
expirationLength={5 * 60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 60 * 1000}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId4"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
expirationLength={5 * 60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 60 * 1000}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
expirationLength={5 * 60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 60 * 1000}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
url: util.gifObjectUrl,
|
||||
contentType: 'image/gif',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={false}
|
||||
isTapToView={true}
|
||||
text="This should not be shown"
|
||||
attachments={[
|
||||
{
|
||||
pending: true,
|
||||
contentType: 'image/gif',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
isTapToViewError={true}
|
||||
text="This should not be shown"
|
||||
attachments={[]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
status="delivered"
|
||||
conversationType="group"
|
||||
timestamp={Date.now()}
|
||||
isTapToViewExpired={true}
|
||||
isTapToView={true}
|
||||
isTapToViewError={true}
|
||||
text="This should not be shown"
|
||||
attachments={[]}
|
||||
i18n={util.i18n}
|
||||
id="messageId6"
|
||||
displayTapToViewMessage={(...args) =>
|
||||
console.log('displayTapToViewMessage', args)
|
||||
}
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### In a group conversation
|
||||
|
||||
Note that the author avatar goes away if `collapseMetadata` is set.
|
||||
|
|
|
@ -80,6 +80,11 @@ export type PropsData = {
|
|||
previews: Array<LinkPreviewType>;
|
||||
authorAvatarPath?: string;
|
||||
isExpired: boolean;
|
||||
|
||||
isTapToView?: boolean;
|
||||
isTapToViewExpired?: boolean;
|
||||
isTapToViewError?: boolean;
|
||||
|
||||
expirationLength?: number;
|
||||
expirationTimestamp?: number;
|
||||
};
|
||||
|
@ -112,6 +117,7 @@ export type PropsActions = {
|
|||
isDangerous: boolean;
|
||||
}
|
||||
) => void;
|
||||
displayTapToViewMessage: (messageId: string) => unknown;
|
||||
|
||||
openLink: (url: string) => void;
|
||||
scrollToMessage: (
|
||||
|
@ -227,6 +233,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
expirationTimestamp,
|
||||
i18n,
|
||||
isSticker,
|
||||
isTapToViewExpired,
|
||||
status,
|
||||
text,
|
||||
textPending,
|
||||
|
@ -274,6 +281,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
direction={metadataDirection}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
withSticker={isSticker}
|
||||
withTapToViewExpired={isTapToViewExpired}
|
||||
module="module-message__metadata__date"
|
||||
/>
|
||||
)}
|
||||
|
@ -284,12 +292,13 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
expirationTimestamp={expirationTimestamp}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
withSticker={isSticker}
|
||||
withTapToViewExpired={isTapToViewExpired}
|
||||
/>
|
||||
) : null}
|
||||
<span className="module-message__metadata__spacer" />
|
||||
{textPending ? (
|
||||
<div className="module-message__metadata__spinner-container">
|
||||
<Spinner size="mini" direction={direction} />
|
||||
<Spinner svgSize="small" size="14px" direction={direction} />
|
||||
</div>
|
||||
) : null}
|
||||
{!textPending && direction === 'outgoing' && status !== 'error' ? (
|
||||
|
@ -302,6 +311,9 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
: null,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__status-icon--with-image-no-caption'
|
||||
: null,
|
||||
isTapToViewExpired
|
||||
? 'module-message__metadata__status-icon--with-tap-to-view-expired'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
|
@ -320,6 +332,8 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
direction,
|
||||
i18n,
|
||||
isSticker,
|
||||
isTapToView,
|
||||
isTapToViewExpired,
|
||||
} = this.props;
|
||||
|
||||
if (collapseMetadata) {
|
||||
|
@ -332,8 +346,13 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const suffix = isSticker ? '_with_sticker' : '';
|
||||
const moduleName = `module-message__author${suffix}`;
|
||||
const withTapToViewExpired = isTapToView && isTapToViewExpired;
|
||||
|
||||
const stickerSuffix = isSticker ? '_with_sticker' : '';
|
||||
const tapToViewSuffix = withTapToViewExpired
|
||||
? '--with-tap-to-view-expired'
|
||||
: '';
|
||||
const moduleName = `module-message__author${stickerSuffix}${tapToViewSuffix}`;
|
||||
|
||||
return (
|
||||
<div className={moduleName}>
|
||||
|
@ -452,7 +471,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
>
|
||||
{pending ? (
|
||||
<div className="module-message__generic-attachment__spinner-container">
|
||||
<Spinner size="small" direction={direction} />
|
||||
<Spinner svgSize="small" size="24px" direction={direction} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="module-message__generic-attachment__icon-container">
|
||||
|
@ -805,6 +824,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
downloadAttachment,
|
||||
id,
|
||||
isSticker,
|
||||
isTapToView,
|
||||
replyToMessage,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
|
@ -822,6 +842,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
const downloadButton =
|
||||
!isSticker &&
|
||||
!multipleAttachments &&
|
||||
!isTapToView &&
|
||||
firstAttachment &&
|
||||
!firstAttachment.pending ? (
|
||||
<div
|
||||
|
@ -886,15 +907,16 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
public renderContextMenu(triggerId: string) {
|
||||
const {
|
||||
attachments,
|
||||
deleteMessage,
|
||||
direction,
|
||||
downloadAttachment,
|
||||
i18n,
|
||||
id,
|
||||
isSticker,
|
||||
deleteMessage,
|
||||
showMessageDetail,
|
||||
isTapToView,
|
||||
replyToMessage,
|
||||
retrySend,
|
||||
showMessageDetail,
|
||||
status,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
|
@ -907,7 +929,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
|
||||
const menu = (
|
||||
<ContextMenu id={triggerId}>
|
||||
{!isSticker && !multipleAttachments && attachments && attachments[0] ? (
|
||||
{!isSticker &&
|
||||
!multipleAttachments &&
|
||||
!isTapToView &&
|
||||
attachments &&
|
||||
attachments[0] ? (
|
||||
<MenuItem
|
||||
attributes={{
|
||||
className: 'module-message__context__download',
|
||||
|
@ -1011,10 +1037,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
}
|
||||
|
||||
public isShowingImage() {
|
||||
const { attachments, previews } = this.props;
|
||||
const { isTapToView, attachments, previews } = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
if (imageBroken) {
|
||||
if (imageBroken || isTapToView) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -1042,17 +1068,153 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
return false;
|
||||
}
|
||||
|
||||
public isAttachmentPending() {
|
||||
const { attachments } = this.props;
|
||||
|
||||
if (!attachments || attachments.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
|
||||
return Boolean(first.pending);
|
||||
}
|
||||
|
||||
public renderTapToViewIcon() {
|
||||
const { direction, isTapToViewExpired } = this.props;
|
||||
const isDownloadPending = this.isAttachmentPending();
|
||||
|
||||
return !isTapToViewExpired && isDownloadPending ? (
|
||||
<div className="module-message__tap-to-view__spinner-container">
|
||||
<Spinner svgSize="small" size="20px" direction={direction} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__tap-to-view__icon',
|
||||
`module-message__tap-to-view__icon--${direction}`,
|
||||
isTapToViewExpired
|
||||
? 'module-message__tap-to-view__icon--expired'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderTapToViewText() {
|
||||
const {
|
||||
direction,
|
||||
i18n,
|
||||
isTapToViewExpired,
|
||||
isTapToViewError,
|
||||
} = this.props;
|
||||
|
||||
const incomingString = isTapToViewExpired
|
||||
? i18n('Message--tap-to-view-expired')
|
||||
: i18n('Message--tap-to-view--incoming');
|
||||
const outgoingString = i18n('Message--tap-to-view--outgoing');
|
||||
const isDownloadPending = this.isAttachmentPending();
|
||||
|
||||
if (isDownloadPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
return isTapToViewError
|
||||
? i18n('incomingError')
|
||||
: direction === 'outgoing'
|
||||
? outgoingString
|
||||
: incomingString;
|
||||
}
|
||||
|
||||
public renderTapToView() {
|
||||
const {
|
||||
collapseMetadata,
|
||||
conversationType,
|
||||
direction,
|
||||
isTapToViewExpired,
|
||||
isTapToViewError,
|
||||
} = this.props;
|
||||
|
||||
const withContentBelow = !collapseMetadata;
|
||||
const withContentAbove =
|
||||
!collapseMetadata &&
|
||||
conversationType === 'group' &&
|
||||
direction === 'incoming';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__tap-to-view',
|
||||
withContentBelow
|
||||
? 'module-message__tap-to-view--with-content-below'
|
||||
: null,
|
||||
withContentAbove
|
||||
? 'module-message__tap-to-view--with-content-above'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
{isTapToViewError ? null : this.renderTapToViewIcon()}
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__tap-to-view__text',
|
||||
`module-message__tap-to-view__text--${direction}`,
|
||||
isTapToViewExpired
|
||||
? `module-message__tap-to-view__text--${direction}-expired`
|
||||
: null,
|
||||
isTapToViewError
|
||||
? `module-message__tap-to-view__text--${direction}-error`
|
||||
: null
|
||||
)}
|
||||
>
|
||||
{this.renderTapToViewText()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContents() {
|
||||
const { isTapToView } = this.props;
|
||||
|
||||
if (isTapToView) {
|
||||
return (
|
||||
<>
|
||||
{this.renderTapToView()}
|
||||
{this.renderMetadata()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.renderQuote()}
|
||||
{this.renderAttachment()}
|
||||
{this.renderPreview()}
|
||||
{this.renderEmbeddedContact()}
|
||||
{this.renderText()}
|
||||
{this.renderMetadata()}
|
||||
{this.renderSendMessageButton()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line cyclomatic-complexity
|
||||
public render() {
|
||||
const {
|
||||
authorPhoneNumber,
|
||||
authorColor,
|
||||
attachments,
|
||||
direction,
|
||||
displayTapToViewMessage,
|
||||
id,
|
||||
isSticker,
|
||||
isTapToView,
|
||||
isTapToViewExpired,
|
||||
isTapToViewError,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { expired, expiring, imageBroken } = this.state;
|
||||
const isAttachmentPending = this.isAttachmentPending();
|
||||
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
|
||||
|
||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||
// It needs to be unique.
|
||||
|
@ -1068,6 +1230,8 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
|
||||
const width = this.getWidth();
|
||||
const isShowingImage = this.isShowingImage();
|
||||
const role = isButton ? 'button' : undefined;
|
||||
const onClick = isButton ? () => displayTapToViewMessage(id) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -1084,22 +1248,31 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
'module-message__container',
|
||||
isSticker ? 'module-message__container--with-sticker' : null,
|
||||
!isSticker ? `module-message__container--${direction}` : null,
|
||||
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
||||
isTapToView && isTapToViewExpired
|
||||
? 'module-message__container--with-tap-to-view-expired'
|
||||
: null,
|
||||
!isSticker && direction === 'incoming'
|
||||
? `module-message__container--incoming-${authorColor}`
|
||||
: null,
|
||||
isTapToView && isAttachmentPending && !isTapToViewExpired
|
||||
? 'module-message__container--with-tap-to-view-pending'
|
||||
: null,
|
||||
isTapToView && isAttachmentPending && !isTapToViewExpired
|
||||
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
|
||||
: null,
|
||||
isTapToViewError
|
||||
? 'module-message__container--with-tap-to-view-error'
|
||||
: null
|
||||
)}
|
||||
style={{
|
||||
width: isShowingImage ? width : undefined,
|
||||
}}
|
||||
role={role}
|
||||
onClick={onClick}
|
||||
>
|
||||
{this.renderAuthor()}
|
||||
{this.renderQuote()}
|
||||
{this.renderAttachment()}
|
||||
{this.renderPreview()}
|
||||
{this.renderEmbeddedContact()}
|
||||
{this.renderText()}
|
||||
{this.renderMetadata()}
|
||||
{this.renderSendMessageButton()}
|
||||
{this.renderContents()}
|
||||
{this.renderAvatar()}
|
||||
</div>
|
||||
{this.renderError(direction === 'outgoing')}
|
||||
|
|
|
@ -12,6 +12,7 @@ interface Props {
|
|||
module?: string;
|
||||
withImageNoCaption?: boolean;
|
||||
withSticker?: boolean;
|
||||
withTapToViewExpired?: boolean;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
@ -50,6 +51,7 @@ export class Timestamp extends React.Component<Props> {
|
|||
timestamp,
|
||||
withImageNoCaption,
|
||||
withSticker,
|
||||
withTapToViewExpired,
|
||||
extended,
|
||||
} = this.props;
|
||||
const moduleName = module || 'module-timestamp';
|
||||
|
@ -63,6 +65,9 @@ export class Timestamp extends React.Component<Props> {
|
|||
className={classNames(
|
||||
moduleName,
|
||||
direction ? `${moduleName}--${direction}` : null,
|
||||
withTapToViewExpired && direction
|
||||
? `${moduleName}--${direction}-with-tap-to-view-expired`
|
||||
: null,
|
||||
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null,
|
||||
withSticker ? `${moduleName}--with-sticker` : null
|
||||
)}
|
||||
|
|
|
@ -25,12 +25,17 @@ export function renderAvatar({
|
|||
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
||||
const pending = avatar && avatar.avatar && avatar.avatar.pending;
|
||||
const name = getName(contact) || '';
|
||||
const spinnerSize = size < 50 ? 'small' : 'normal';
|
||||
const spinnerSvgSize = size < 50 ? 'small' : 'normal';
|
||||
const spinnerSize = size < 50 ? '24px' : undefined;
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className="module-embedded-contact__spinner-container">
|
||||
<Spinner size={spinnerSize} direction={direction} />
|
||||
<Spinner
|
||||
svgSize={spinnerSvgSize}
|
||||
size={spinnerSize}
|
||||
direction={direction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ function renderBody({ pack, i18n }: Props) {
|
|||
}
|
||||
|
||||
if (!pack || pack.stickerCount === 0 || !isNumber(pack.stickerCount)) {
|
||||
return <Spinner size="normal" />;
|
||||
return <Spinner svgSize="normal" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -209,7 +209,7 @@ export const StickerPreviewModal = React.memo(
|
|||
</div>
|
||||
<div className="module-sticker-manager__preview-modal__container__meta-overlay__install">
|
||||
{pack.status === 'pending' ? (
|
||||
<Spinner size="mini" />
|
||||
<Spinner svgSize="small" size="14px" />
|
||||
) : (
|
||||
<StickerPackInstallButton
|
||||
ref={focusRef}
|
||||
|
|
|
@ -18,6 +18,7 @@ export type IncomingMessage = Readonly<
|
|||
decrypted_at?: number;
|
||||
errors?: Array<any>;
|
||||
expireTimer?: number;
|
||||
messageTimer?: number;
|
||||
flags?: number;
|
||||
source?: string;
|
||||
sourceDevice?: number;
|
||||
|
@ -46,6 +47,7 @@ export type OutgoingMessage = Readonly<
|
|||
body?: string;
|
||||
expires_at?: number;
|
||||
expireTimer?: number;
|
||||
messageTimer?: number;
|
||||
recipients?: Array<string>; // Array<PhoneNumber>
|
||||
synced: boolean;
|
||||
} & SharedMessageProperties &
|
||||
|
|
|
@ -16,6 +16,9 @@ export const initializeAttachmentMetadata = async (
|
|||
if (message.type === 'verified-change') {
|
||||
return message;
|
||||
}
|
||||
if (message.messageTimer) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const attachments = message.attachments.filter(
|
||||
(attachment: Attachment.Attachment) =>
|
||||
|
|
|
@ -6095,7 +6095,7 @@
|
|||
"rule": "React-createRef",
|
||||
"path": "ts/components/Lightbox.js",
|
||||
"line": " this.videoRef = react_1.default.createRef();",
|
||||
"lineNumber": 180,
|
||||
"lineNumber": 183,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-03-09T00:08:44.242Z",
|
||||
"reasonDetail": "Used to auto-start playback on videos"
|
||||
|
@ -6104,7 +6104,7 @@
|
|||
"rule": "React-createRef",
|
||||
"path": "ts/components/Lightbox.tsx",
|
||||
"line": " this.videoRef = React.createRef();",
|
||||
"lineNumber": 176,
|
||||
"lineNumber": 179,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-03-09T00:08:44.242Z",
|
||||
"reasonDetail": "Used to auto-start playback on videos"
|
||||
|
|
Loading…
Add table
Reference in a new issue