Persist drafts
This commit is contained in:
parent
5ebd8bc690
commit
9d4f2afa5a
23 changed files with 1048 additions and 720 deletions
|
@ -1720,6 +1720,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
"ConversationListItem--draft-prefix": {
|
||||
"message": "Draft:",
|
||||
"description":
|
||||
"Prefix shown in italic in conversation view when a draft is saved"
|
||||
},
|
||||
"message--getNotificationText--stickers": {
|
||||
"message": "Sticker message",
|
||||
"description":
|
||||
|
@ -1906,5 +1912,20 @@
|
|||
"message": "View Photo",
|
||||
"description":
|
||||
"Text shown on messages with with individual timers, before user has viewed it"
|
||||
},
|
||||
"Conversation--getDraftPreview--attachment": {
|
||||
"message": "(attachment)",
|
||||
"description":
|
||||
"Text shown in left pane as preview for conversation with saved a saved draft message"
|
||||
},
|
||||
"Conversation--getDraftPreview--quote": {
|
||||
"message": "(quote)",
|
||||
"description":
|
||||
"Text shown in left pane as preview for conversation with saved a saved draft message"
|
||||
},
|
||||
"Conversation--getDraftPreview--draft": {
|
||||
"message": "(draft)",
|
||||
"description":
|
||||
"Text shown in left pane as preview for conversation with saved a saved draft message"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ let initialized = false;
|
|||
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
|
||||
const ERASE_STICKERS_KEY = 'erase-stickers';
|
||||
const ERASE_TEMP_KEY = 'erase-temp';
|
||||
const ERASE_DRAFTS_KEY = 'erase-drafts';
|
||||
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
|
||||
|
||||
async function initialize({ configDir, cleanupOrphanedAttachments }) {
|
||||
|
@ -24,6 +25,7 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) {
|
|||
const attachmentsDir = Attachments.getPath(configDir);
|
||||
const stickersDir = Attachments.getStickersPath(configDir);
|
||||
const tempDir = Attachments.getTempPath(configDir);
|
||||
const draftDir = Attachments.getDraftPath(configDir);
|
||||
|
||||
ipcMain.on(ERASE_TEMP_KEY, event => {
|
||||
try {
|
||||
|
@ -58,6 +60,17 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) {
|
|||
}
|
||||
});
|
||||
|
||||
ipcMain.on(ERASE_DRAFTS_KEY, event => {
|
||||
try {
|
||||
rimraf.sync(draftDir);
|
||||
event.sender.send(`${ERASE_DRAFTS_KEY}-done`);
|
||||
} catch (error) {
|
||||
const errorForDisplay = error && error.stack ? error.stack : error;
|
||||
console.log(`erase drafts error: ${errorForDisplay}`);
|
||||
event.sender.send(`${ERASE_DRAFTS_KEY}-done`, error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async event => {
|
||||
try {
|
||||
await cleanupOrphanedAttachments();
|
||||
|
|
|
@ -10,6 +10,7 @@ const { map, isArrayBuffer, isString } = require('lodash');
|
|||
const PATH = 'attachments.noindex';
|
||||
const STICKER_PATH = 'stickers.noindex';
|
||||
const TEMP_PATH = 'temp';
|
||||
const DRAFT_PATH = 'drafts.noindex';
|
||||
|
||||
exports.getAllAttachments = async userDataPath => {
|
||||
const dir = exports.getPath(userDataPath);
|
||||
|
@ -27,6 +28,14 @@ exports.getAllStickers = async userDataPath => {
|
|||
return map(files, file => path.relative(dir, file));
|
||||
};
|
||||
|
||||
exports.getAllDraftAttachments = async userDataPath => {
|
||||
const dir = exports.getDraftPath(userDataPath);
|
||||
const pattern = path.join(dir, '**', '*');
|
||||
|
||||
const files = await pify(glob)(pattern, { nodir: true });
|
||||
return map(files, file => path.relative(dir, file));
|
||||
};
|
||||
|
||||
// getPath :: AbsolutePath -> AbsolutePath
|
||||
exports.getPath = userDataPath => {
|
||||
if (!isString(userDataPath)) {
|
||||
|
@ -51,6 +60,14 @@ exports.getTempPath = userDataPath => {
|
|||
return path.join(userDataPath, TEMP_PATH);
|
||||
};
|
||||
|
||||
// getDraftPath :: AbsolutePath -> AbsolutePath
|
||||
exports.getDraftPath = userDataPath => {
|
||||
if (!isString(userDataPath)) {
|
||||
throw new TypeError("'userDataPath' must be a string");
|
||||
}
|
||||
return path.join(userDataPath, DRAFT_PATH);
|
||||
};
|
||||
|
||||
// clearTempPath :: AbsolutePath -> AbsolutePath
|
||||
exports.clearTempPath = userDataPath => {
|
||||
const tempPath = exports.getTempPath(userDataPath);
|
||||
|
@ -204,6 +221,20 @@ exports.deleteAllStickers = async ({ userDataPath, stickers }) => {
|
|||
console.log(`deleteAllStickers: deleted ${stickers.length} files`);
|
||||
};
|
||||
|
||||
exports.deleteAllDraftAttachments = async ({ userDataPath, stickers }) => {
|
||||
const deleteFromDisk = exports.createDeleter(
|
||||
exports.getDraftPath(userDataPath)
|
||||
);
|
||||
|
||||
for (let index = 0, max = stickers.length; index < max; index += 1) {
|
||||
const file = stickers[index];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteFromDisk(file);
|
||||
}
|
||||
|
||||
console.log(`deleteAllDraftAttachments: deleted ${stickers.length} files`);
|
||||
};
|
||||
|
||||
// createName :: Unit -> IO String
|
||||
exports.createName = () => {
|
||||
const buffer = crypto.randomBytes(32);
|
||||
|
|
70
app/sql.js
70
app/sql.js
|
@ -140,6 +140,7 @@ module.exports = {
|
|||
|
||||
removeKnownAttachments,
|
||||
removeKnownStickers,
|
||||
removeKnownDraftAttachments,
|
||||
};
|
||||
|
||||
function generateUUID() {
|
||||
|
@ -2867,6 +2868,24 @@ function getExternalFilesForConversation(conversation) {
|
|||
return files;
|
||||
}
|
||||
|
||||
function getExternalDraftFilesForConversation(conversation) {
|
||||
const draftAttachments = conversation.draftAttachments || [];
|
||||
const files = [];
|
||||
|
||||
forEach(draftAttachments, attachment => {
|
||||
const { path: file, screenshotPath } = attachment;
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
if (screenshotPath) {
|
||||
files.push(screenshotPath);
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function removeKnownAttachments(allAttachments) {
|
||||
const lookup = fromPairs(map(allAttachments, file => [file, true]));
|
||||
const chunkSize = 50;
|
||||
|
@ -2999,3 +3018,54 @@ async function removeKnownStickers(allStickers) {
|
|||
|
||||
return Object.keys(lookup);
|
||||
}
|
||||
|
||||
async function removeKnownDraftAttachments(allStickers) {
|
||||
const lookup = fromPairs(map(allStickers, file => [file, true]));
|
||||
const chunkSize = 50;
|
||||
|
||||
const total = await getConversationCount();
|
||||
console.log(
|
||||
`removeKnownDraftAttachments: About to iterate through ${total} conversations`
|
||||
);
|
||||
|
||||
let complete = false;
|
||||
let count = 0;
|
||||
// Though conversations.id is a string, this ensures that, when coerced, this
|
||||
// value is still a string but it's smaller than every other string.
|
||||
let id = 0;
|
||||
|
||||
while (!complete) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const rows = await db.all(
|
||||
`SELECT json FROM conversations
|
||||
WHERE id > $id
|
||||
ORDER BY id ASC
|
||||
LIMIT $chunkSize;`,
|
||||
{
|
||||
$id: id,
|
||||
$chunkSize: chunkSize,
|
||||
}
|
||||
);
|
||||
|
||||
const conversations = map(rows, row => jsonToObject(row.json));
|
||||
forEach(conversations, conversation => {
|
||||
const externalFiles = getExternalDraftFilesForConversation(conversation);
|
||||
forEach(externalFiles, file => {
|
||||
delete lookup[file];
|
||||
});
|
||||
});
|
||||
|
||||
const lastMessage = last(conversations);
|
||||
if (lastMessage) {
|
||||
({ id } = lastMessage);
|
||||
}
|
||||
complete = conversations.length < chunkSize;
|
||||
count += conversations.length;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`removeKnownDraftAttachments: Done processing ${count} conversations`
|
||||
);
|
||||
|
||||
return Object.keys(lookup);
|
||||
}
|
||||
|
|
|
@ -471,7 +471,6 @@
|
|||
<script type='text/javascript' src='js/views/react_wrapper_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/whisper_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/toast_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/file_input_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/list_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/contact_list_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
|
||||
|
|
|
@ -150,6 +150,34 @@
|
|||
return this.id === this.ourNumber;
|
||||
},
|
||||
|
||||
hasDraft() {
|
||||
const draftAttachments = this.get('draftAttachments') || [];
|
||||
return (
|
||||
this.get('draft') ||
|
||||
this.get('quotedMessageId') ||
|
||||
draftAttachments.length > 0
|
||||
);
|
||||
},
|
||||
|
||||
getDraftPreview() {
|
||||
const draft = this.get('draft');
|
||||
if (draft) {
|
||||
return draft;
|
||||
}
|
||||
|
||||
const draftAttachments = this.get('draftAttachments') || [];
|
||||
if (draftAttachments.length > 0) {
|
||||
return i18n('Conversation--getDraftPreview--attachment');
|
||||
}
|
||||
|
||||
const quotedMessageId = this.get('quotedMessageId');
|
||||
if (quotedMessageId) {
|
||||
return i18n('Conversation--getDraftPreview--quote');
|
||||
}
|
||||
|
||||
return i18n('Conversation--getDraftPreview--draft');
|
||||
},
|
||||
|
||||
bumpTyping() {
|
||||
// We don't send typing messages if the setting is disabled
|
||||
if (!storage.get('typingIndicators')) {
|
||||
|
@ -327,6 +355,13 @@
|
|||
? ConversationController.getOrCreate(typingMostRecent.sender, 'private')
|
||||
: null;
|
||||
|
||||
const timestamp = this.get('timestamp');
|
||||
const draftTimestamp = this.get('draftTimestamp');
|
||||
const draftPreview = this.getDraftPreview();
|
||||
const draftText = this.get('draft');
|
||||
const shouldShowDraft =
|
||||
this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp;
|
||||
|
||||
const result = {
|
||||
id: this.id,
|
||||
|
||||
|
@ -340,10 +375,14 @@
|
|||
lastUpdated: this.get('timestamp'),
|
||||
name: this.getName(),
|
||||
profileName: this.getProfileName(),
|
||||
timestamp: this.get('timestamp'),
|
||||
timestamp,
|
||||
title: this.getTitle(),
|
||||
unreadCount: this.get('unreadCount') || 0,
|
||||
|
||||
shouldShowDraft,
|
||||
draftPreview,
|
||||
draftText,
|
||||
|
||||
phoneNumber: format(this.id, {
|
||||
ourRegionCode: regionCode,
|
||||
}),
|
||||
|
@ -970,6 +1009,8 @@
|
|||
active_at: now,
|
||||
timestamp: now,
|
||||
isArchived: false,
|
||||
draft: null,
|
||||
draftTimestamp: null,
|
||||
});
|
||||
await window.Signal.Data.updateConversation(this.id, this.attributes, {
|
||||
Conversation: Whisper.Conversation,
|
||||
|
@ -1226,6 +1267,15 @@
|
|||
);
|
||||
|
||||
const lastMessageModel = messages.at(0);
|
||||
if (
|
||||
this.hasDraft() &&
|
||||
this.get('draftTimestamp') &&
|
||||
(!lastMessageModel ||
|
||||
lastMessageModel.get('sent_at') < this.get('draftTimestamp'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMessageJSON = lastMessageModel
|
||||
? lastMessageModel.toJSON()
|
||||
: null;
|
||||
|
|
|
@ -9,7 +9,6 @@ const {
|
|||
isFunction,
|
||||
isObject,
|
||||
map,
|
||||
merge,
|
||||
set,
|
||||
} = require('lodash');
|
||||
|
||||
|
@ -29,6 +28,7 @@ const ERASE_SQL_KEY = 'erase-sql-key';
|
|||
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
|
||||
const ERASE_STICKERS_KEY = 'erase-stickers';
|
||||
const ERASE_TEMP_KEY = 'erase-temp';
|
||||
const ERASE_DRAFTS_KEY = 'erase-drafts';
|
||||
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
|
||||
|
||||
const _jobs = Object.create(null);
|
||||
|
@ -598,7 +598,10 @@ async function updateConversation(id, data, { Conversation }) {
|
|||
throw new Error(`Conversation ${id} does not exist!`);
|
||||
}
|
||||
|
||||
const merged = merge({}, existing.attributes, data);
|
||||
const merged = {
|
||||
...existing.attributes,
|
||||
...data,
|
||||
};
|
||||
await channels.updateConversation(merged);
|
||||
}
|
||||
|
||||
|
@ -1007,6 +1010,7 @@ async function removeOtherData() {
|
|||
callChannel(ERASE_ATTACHMENTS_KEY),
|
||||
callChannel(ERASE_STICKERS_KEY),
|
||||
callChannel(ERASE_TEMP_KEY),
|
||||
callChannel(ERASE_DRAFTS_KEY),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -103,20 +103,21 @@ function initializeMigrations({
|
|||
return null;
|
||||
}
|
||||
const {
|
||||
createAbsolutePathGetter,
|
||||
createReader,
|
||||
createWriterForExisting,
|
||||
createWriterForNew,
|
||||
getDraftPath,
|
||||
getPath,
|
||||
getStickersPath,
|
||||
getTempPath,
|
||||
createReader,
|
||||
createAbsolutePathGetter,
|
||||
createWriterForNew,
|
||||
createWriterForExisting,
|
||||
} = Attachments;
|
||||
const {
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeObjectUrl,
|
||||
makeVideoScreenshot,
|
||||
revokeObjectUrl,
|
||||
} = VisualType;
|
||||
|
||||
const attachmentsPath = getPath(userDataPath);
|
||||
|
@ -147,11 +148,18 @@ function initializeMigrations({
|
|||
tempPath
|
||||
);
|
||||
|
||||
const draftPath = getDraftPath(userDataPath);
|
||||
const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath);
|
||||
const writeNewDraftData = createWriterForNew(draftPath);
|
||||
const deleteDraftFile = Attachments.createDeleter(draftPath);
|
||||
const readDraftData = createReader(draftPath);
|
||||
|
||||
return {
|
||||
attachmentsPath,
|
||||
copyIntoAttachmentsDirectory,
|
||||
copyIntoTempDirectory,
|
||||
deleteAttachmentData: deleteOnDisk,
|
||||
deleteDraftFile,
|
||||
deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({
|
||||
deleteAttachmentData: Type.deleteData(deleteOnDisk),
|
||||
deleteOnDisk,
|
||||
|
@ -159,6 +167,7 @@ function initializeMigrations({
|
|||
deleteSticker,
|
||||
deleteTempFile,
|
||||
getAbsoluteAttachmentPath,
|
||||
getAbsoluteDraftPath,
|
||||
getAbsoluteStickerPath,
|
||||
getAbsoluteTempPath,
|
||||
getPlaceholderMigrations,
|
||||
|
@ -169,6 +178,7 @@ function initializeMigrations({
|
|||
loadQuoteData,
|
||||
loadStickerData,
|
||||
readAttachmentData,
|
||||
readDraftData,
|
||||
readStickerData,
|
||||
readTempData,
|
||||
run,
|
||||
|
@ -218,6 +228,7 @@ function initializeMigrations({
|
|||
logger,
|
||||
}),
|
||||
writeNewAttachmentData: createWriterForNew(attachmentsPath),
|
||||
writeNewDraftData,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,575 +0,0 @@
|
|||
/* global textsecure: false */
|
||||
/* global Whisper: false */
|
||||
/* global i18n: false */
|
||||
/* global loadImage: false */
|
||||
/* global Backbone: false */
|
||||
/* global _: false */
|
||||
/* global Signal: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { MIME, VisualAttachment } = window.Signal.Types;
|
||||
|
||||
Whisper.FileSizeToast = Whisper.ToastView.extend({
|
||||
templateName: 'file-size-modal',
|
||||
render_attributes() {
|
||||
return {
|
||||
'file-size-warning': i18n('fileSizeWarning'),
|
||||
limit: this.model.limit,
|
||||
units: this.model.units,
|
||||
};
|
||||
},
|
||||
});
|
||||
Whisper.UnableToLoadToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: i18n('unableToLoadAttachment') };
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
|
||||
template: i18n('dangerousFileType'),
|
||||
});
|
||||
Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({
|
||||
template: i18n('oneNonImageAtATimeToast'),
|
||||
});
|
||||
Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({
|
||||
template: i18n('cannotMixImageAdnNonImageAttachments'),
|
||||
});
|
||||
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
|
||||
template: i18n('maximumAttachments'),
|
||||
});
|
||||
|
||||
Whisper.FileInputView = Backbone.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'file-input',
|
||||
initialize() {
|
||||
this.attachments = [];
|
||||
|
||||
this.attachmentListView = new Whisper.ReactWrapperView({
|
||||
el: this.el,
|
||||
Component: window.Signal.Components.AttachmentList,
|
||||
props: this.getPropsForAttachmentList(),
|
||||
});
|
||||
},
|
||||
|
||||
remove() {
|
||||
if (this.attachmentListView) {
|
||||
this.attachmentListView.remove();
|
||||
}
|
||||
if (this.captionEditorView) {
|
||||
this.captionEditorView.remove();
|
||||
}
|
||||
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
},
|
||||
|
||||
render() {
|
||||
this.attachmentListView.update(this.getPropsForAttachmentList());
|
||||
this.trigger('staged-attachments-changed');
|
||||
},
|
||||
|
||||
getPropsForAttachmentList() {
|
||||
const { attachments } = this;
|
||||
|
||||
// We never want to display voice notes in our attachment list
|
||||
if (_.any(attachments, attachment => Boolean(attachment.isVoiceNote))) {
|
||||
return {
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
attachments,
|
||||
onAddAttachment: this.onAddAttachment.bind(this),
|
||||
onClickAttachment: this.onClickAttachment.bind(this),
|
||||
onCloseAttachment: this.onCloseAttachment.bind(this),
|
||||
onClose: this.onClose.bind(this),
|
||||
};
|
||||
},
|
||||
|
||||
onClickAttachment(attachment) {
|
||||
const getProps = () => ({
|
||||
url: attachment.videoUrl || attachment.url,
|
||||
caption: attachment.caption,
|
||||
attachment,
|
||||
onSave,
|
||||
});
|
||||
|
||||
const onSave = caption => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
attachment.caption = caption;
|
||||
this.captionEditorView.remove();
|
||||
Signal.Backbone.Views.Lightbox.hide();
|
||||
this.render();
|
||||
};
|
||||
|
||||
this.captionEditorView = new Whisper.ReactWrapperView({
|
||||
className: 'attachment-list-wrapper',
|
||||
Component: window.Signal.Components.CaptionEditor,
|
||||
props: getProps(),
|
||||
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
|
||||
});
|
||||
Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
|
||||
},
|
||||
|
||||
onCloseAttachment(attachment) {
|
||||
this.attachments = _.without(this.attachments, attachment);
|
||||
this.render();
|
||||
},
|
||||
|
||||
onAddAttachment() {
|
||||
this.trigger('choose-attachment');
|
||||
},
|
||||
|
||||
onClose() {
|
||||
this.attachments = [];
|
||||
this.render();
|
||||
},
|
||||
|
||||
// These event handlers are called by ConversationView, which listens for these events
|
||||
|
||||
onDragOver(e) {
|
||||
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.$el.addClass('dropoff');
|
||||
},
|
||||
|
||||
onDragLeave(e) {
|
||||
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.$el.removeClass('dropoff');
|
||||
},
|
||||
|
||||
async onDrop(e) {
|
||||
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const { files } = e.originalEvent.dataTransfer;
|
||||
for (let i = 0, max = files.length; i < max; i += 1) {
|
||||
const file = files[i];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.maybeAddAttachment(file);
|
||||
}
|
||||
|
||||
this.$el.removeClass('dropoff');
|
||||
},
|
||||
|
||||
onPaste(e) {
|
||||
const { items } = e.originalEvent.clipboardData;
|
||||
let imgBlob = null;
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
if (items[i].type.split('/')[0] === 'image') {
|
||||
imgBlob = items[i].getAsFile();
|
||||
}
|
||||
}
|
||||
if (imgBlob !== null) {
|
||||
const file = imgBlob;
|
||||
this.maybeAddAttachment(file);
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
// Public interface
|
||||
|
||||
hasFiles() {
|
||||
return this.attachments.length > 0;
|
||||
},
|
||||
|
||||
async getFiles() {
|
||||
const files = await Promise.all(
|
||||
this.attachments.map(attachment => this.getFile(attachment))
|
||||
);
|
||||
this.clearAttachments();
|
||||
return files;
|
||||
},
|
||||
|
||||
clearAttachments() {
|
||||
this.attachments.forEach(attachment => {
|
||||
if (attachment.url) {
|
||||
URL.revokeObjectURL(attachment.url);
|
||||
}
|
||||
if (attachment.videoUrl) {
|
||||
URL.revokeObjectURL(attachment.videoUrl);
|
||||
}
|
||||
});
|
||||
|
||||
this.attachments = [];
|
||||
this.render();
|
||||
this.$el.trigger('force-resize');
|
||||
},
|
||||
|
||||
// Show errors
|
||||
|
||||
showLoadFailure() {
|
||||
const toast = new Whisper.UnableToLoadToast();
|
||||
toast.$el.insertAfter(this.$el);
|
||||
toast.render();
|
||||
},
|
||||
|
||||
showDangerousError() {
|
||||
const toast = new Whisper.DangerousFileTypeToast();
|
||||
toast.$el.insertAfter(this.$el);
|
||||
toast.render();
|
||||
},
|
||||
|
||||
showFileSizeError({ limit, units, u }) {
|
||||
const toast = new Whisper.FileSizeToast({
|
||||
model: { limit, units: units[u] },
|
||||
});
|
||||
toast.$el.insertAfter(this.$el);
|
||||
toast.render();
|
||||
},
|
||||
|
||||
showCannotMixError() {
|
||||
const toast = new Whisper.CannotMixImageAndNonImageAttachmentsToast();
|
||||
toast.$el.insertAfter(this.$el);
|
||||
toast.render();
|
||||
},
|
||||
|
||||
showMultipleNonImageError() {
|
||||
const toast = new Whisper.OneNonImageAtATimeToast();
|
||||
toast.$el.insertAfter(this.$el);
|
||||
toast.render();
|
||||
},
|
||||
|
||||
showMaximumAttachmentsError() {
|
||||
const toast = new Whisper.MaxAttachmentsToast();
|
||||
toast.$el.insertAfter(this.$el);
|
||||
toast.render();
|
||||
},
|
||||
|
||||
// Housekeeping
|
||||
|
||||
addAttachment(attachment) {
|
||||
if (attachment.isVoiceNote && this.attachments.length > 0) {
|
||||
throw new Error('A voice note cannot be sent with other attachments');
|
||||
}
|
||||
|
||||
this.attachments.push(attachment);
|
||||
this.render();
|
||||
},
|
||||
|
||||
async maybeAddAttachment(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = file.name;
|
||||
const contentType = file.type;
|
||||
|
||||
if (window.Signal.Util.isFileDangerous(fileName)) {
|
||||
this.showDangerousError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.attachments.length >= 32) {
|
||||
this.showMaximumAttachmentsError();
|
||||
return;
|
||||
}
|
||||
|
||||
const haveNonImage = _.any(
|
||||
this.attachments,
|
||||
attachment => !MIME.isImage(attachment.contentType)
|
||||
);
|
||||
// You can't add another attachment if you already have a non-image staged
|
||||
if (haveNonImage) {
|
||||
this.showMultipleNonImageError();
|
||||
return;
|
||||
}
|
||||
|
||||
// You can't add a non-image attachment if you already have attachments staged
|
||||
if (!MIME.isImage(contentType) && this.attachments.length > 0) {
|
||||
this.showCannotMixError();
|
||||
return;
|
||||
}
|
||||
|
||||
const renderVideoPreview = async () => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const type = 'image/png';
|
||||
const thumbnail = await VisualAttachment.makeVideoScreenshot({
|
||||
objectUrl,
|
||||
contentType: type,
|
||||
logger: window.log,
|
||||
});
|
||||
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
|
||||
const url = Signal.Util.arrayBufferToObjectURL({
|
||||
data,
|
||||
type,
|
||||
});
|
||||
this.addAttachment({
|
||||
file,
|
||||
size: file.size,
|
||||
fileName,
|
||||
contentType,
|
||||
videoUrl: objectUrl,
|
||||
url,
|
||||
});
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const renderImagePreview = async () => {
|
||||
if (!MIME.isJPEG(contentType)) {
|
||||
const url = URL.createObjectURL(file);
|
||||
if (!url) {
|
||||
throw new Error('Failed to create object url for image!');
|
||||
}
|
||||
this.addAttachment({
|
||||
file,
|
||||
size: file.size,
|
||||
fileName,
|
||||
contentType,
|
||||
url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = await window.autoOrientImage(file);
|
||||
this.addAttachment({
|
||||
file,
|
||||
size: file.size,
|
||||
fileName,
|
||||
contentType,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const blob = await this.autoScale({
|
||||
contentType,
|
||||
file,
|
||||
});
|
||||
let limitKb = 1000000;
|
||||
const blobType =
|
||||
file.type === 'image/gif' ? 'gif' : contentType.split('/')[0];
|
||||
|
||||
switch (blobType) {
|
||||
case 'image':
|
||||
limitKb = 6000;
|
||||
break;
|
||||
case 'gif':
|
||||
limitKb = 25000;
|
||||
break;
|
||||
case 'audio':
|
||||
limitKb = 100000;
|
||||
break;
|
||||
case 'video':
|
||||
limitKb = 100000;
|
||||
break;
|
||||
default:
|
||||
limitKb = 100000;
|
||||
break;
|
||||
}
|
||||
if ((blob.file.size / 1024).toFixed(4) >= limitKb) {
|
||||
const units = ['kB', 'MB', 'GB'];
|
||||
let u = -1;
|
||||
let limit = limitKb * 1000;
|
||||
do {
|
||||
limit /= 1000;
|
||||
u += 1;
|
||||
} while (limit >= 1000 && u < units.length - 1);
|
||||
this.showFileSizeError({ limit, units, u });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Error ensuring that image is properly sized:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
|
||||
this.showLoadFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
await renderImagePreview();
|
||||
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
await renderVideoPreview();
|
||||
} else {
|
||||
this.addAttachment({
|
||||
file,
|
||||
size: file.size,
|
||||
contentType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
window.log.error(
|
||||
`Was unable to generate thumbnail for file type ${contentType}`,
|
||||
e && e.stack ? e.stack : e
|
||||
);
|
||||
this.addAttachment({
|
||||
file,
|
||||
size: file.size,
|
||||
contentType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
autoScale(attachment) {
|
||||
const { contentType, file } = attachment;
|
||||
if (
|
||||
contentType.split('/')[0] !== 'image' ||
|
||||
contentType === 'image/tiff'
|
||||
) {
|
||||
// nothing to do
|
||||
return Promise.resolve(attachment);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = document.createElement('img');
|
||||
img.onerror = reject;
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const maxSize = 6000 * 1024;
|
||||
const maxHeight = 4096;
|
||||
const maxWidth = 4096;
|
||||
if (
|
||||
img.naturalWidth <= maxWidth &&
|
||||
img.naturalHeight <= maxHeight &&
|
||||
file.size <= maxSize
|
||||
) {
|
||||
resolve(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
const gifMaxSize = 25000 * 1024;
|
||||
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
|
||||
resolve(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type === 'image/gif') {
|
||||
reject(new Error('GIF is too large'));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetContentType = 'image/jpeg';
|
||||
const canvas = loadImage.scale(img, {
|
||||
canvas: true,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
});
|
||||
|
||||
let quality = 0.95;
|
||||
let i = 4;
|
||||
let blob;
|
||||
do {
|
||||
i -= 1;
|
||||
blob = window.dataURLToBlobSync(
|
||||
canvas.toDataURL(targetContentType, quality)
|
||||
);
|
||||
quality = quality * maxSize / blob.size;
|
||||
// NOTE: During testing with a large image, we observed the
|
||||
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
|
||||
if (quality < 0.5) {
|
||||
quality = 0.5;
|
||||
}
|
||||
} while (i > 0 && blob.size > maxSize);
|
||||
|
||||
resolve({
|
||||
...attachment,
|
||||
fileName: this.fixExtension(attachment.fileName, targetContentType),
|
||||
contentType: targetContentType,
|
||||
file: blob,
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
|
||||
getFileName(fileName) {
|
||||
if (!fileName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!fileName.includes('.')) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return fileName
|
||||
.split('.')
|
||||
.slice(0, -1)
|
||||
.join('.');
|
||||
},
|
||||
|
||||
getType(contentType) {
|
||||
if (!contentType) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!contentType.includes('/')) {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
return contentType.split('/')[1];
|
||||
},
|
||||
|
||||
fixExtension(fileName, contentType) {
|
||||
const extension = this.getType(contentType);
|
||||
const name = this.getFileName(fileName);
|
||||
return `${name}.${extension}`;
|
||||
},
|
||||
|
||||
async getFile(attachment) {
|
||||
if (!attachment) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const attachmentFlags = attachment.isVoiceNote
|
||||
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
|
||||
: null;
|
||||
|
||||
const scaled = await this.autoScale(attachment);
|
||||
const fileRead = await this.readFile(scaled);
|
||||
return {
|
||||
...fileRead,
|
||||
url: undefined,
|
||||
videoUrl: undefined,
|
||||
flags: attachmentFlags || null,
|
||||
};
|
||||
},
|
||||
|
||||
readFile(attachment) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const FR = new FileReader();
|
||||
FR.onload = e => {
|
||||
const data = e.target.result;
|
||||
resolve({
|
||||
...attachment,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
});
|
||||
};
|
||||
FR.onerror = reject;
|
||||
FR.onabort = reject;
|
||||
FR.readAsArrayBuffer(attachment.file);
|
||||
});
|
||||
},
|
||||
});
|
||||
})();
|
11
main.js
11
main.js
|
@ -724,6 +724,17 @@ app.on('ready', async () => {
|
|||
userDataPath,
|
||||
stickers: orphanedStickers,
|
||||
});
|
||||
|
||||
const allDraftAttachments = await attachments.getAllDraftAttachments(
|
||||
userDataPath
|
||||
);
|
||||
const orphanedDraftAttachments = await sql.removeKnownDraftAttachments(
|
||||
allDraftAttachments
|
||||
);
|
||||
await attachments.deleteAllDraftAttachments({
|
||||
userDataPath,
|
||||
stickers: orphanedDraftAttachments,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
14
preload.js
14
preload.js
|
@ -5,8 +5,17 @@ const semver = require('semver');
|
|||
|
||||
const { deferredToPromise } = require('./js/modules/deferred_to_promise');
|
||||
|
||||
const { app } = electron.remote;
|
||||
const { systemPreferences } = electron.remote.require('electron');
|
||||
const { remote } = electron;
|
||||
const { app } = remote;
|
||||
const { systemPreferences } = remote.require('electron');
|
||||
|
||||
const browserWindow = remote.getCurrentWindow();
|
||||
let focusHandlers = [];
|
||||
browserWindow.on('focus', () => focusHandlers.forEach(handler => handler()));
|
||||
window.registerForFocus = handler => focusHandlers.push(handler);
|
||||
window.unregisterForFocus = handler => {
|
||||
focusHandlers = focusHandlers.filter(item => item !== handler);
|
||||
};
|
||||
|
||||
// Waiting for clients to implement changes on receive side
|
||||
window.ENABLE_STICKER_SEND = true;
|
||||
|
@ -308,6 +317,7 @@ const userDataPath = app.getPath('userData');
|
|||
window.baseAttachmentsPath = Attachments.getPath(userDataPath);
|
||||
window.baseStickersPath = Attachments.getStickersPath(userDataPath);
|
||||
window.baseTempPath = Attachments.getTempPath(userDataPath);
|
||||
window.baseDraftPath = Attachments.getDraftPath(userDataPath);
|
||||
window.Signal = Signal.setup({
|
||||
Attachments,
|
||||
userDataPath,
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
width: 100%;
|
||||
margin-top: 10px;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
visibility: hidden;
|
||||
display: block;
|
||||
font-size: 0;
|
||||
|
@ -1689,7 +1689,7 @@
|
|||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: '.';
|
||||
visibility: hidden;
|
||||
display: block;
|
||||
|
@ -2146,6 +2146,11 @@
|
|||
color: $color-gray-90;
|
||||
}
|
||||
|
||||
.module-conversation-list-item__message__draft-prefix {
|
||||
font-style: italic;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.module-conversation-list-item__message__status-icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
|
@ -2387,7 +2392,7 @@
|
|||
color: $color-gray-90;
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
&:placeholder {
|
||||
color: $color-gray-45;
|
||||
}
|
||||
|
||||
|
@ -2898,7 +2903,7 @@
|
|||
padding-left: 12px;
|
||||
padding-right: 65px;
|
||||
|
||||
&::placeholder {
|
||||
&:placeholder {
|
||||
color: $color-white-07;
|
||||
}
|
||||
&:focus {
|
||||
|
@ -3861,7 +3866,7 @@
|
|||
background: none;
|
||||
border: 0;
|
||||
&--menu {
|
||||
&:after {
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
min-width: 24px;
|
||||
|
@ -4123,7 +4128,7 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
width: 24px;
|
||||
|
@ -4447,7 +4452,7 @@
|
|||
border-color: $color-signal-blue;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
&:placeholder {
|
||||
color: $color-gray-45;
|
||||
}
|
||||
}
|
||||
|
@ -4461,7 +4466,7 @@
|
|||
border-color: $color-signal-blue;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
&:placeholder {
|
||||
color: $color-gray-45;
|
||||
}
|
||||
}
|
||||
|
@ -4642,7 +4647,7 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
width: 24px;
|
||||
|
@ -4987,7 +4992,7 @@
|
|||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
&:after {
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
width: 24px;
|
||||
|
|
|
@ -484,7 +484,6 @@
|
|||
<script type='text/javascript' src='../js/views/whisper_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/debug_log_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/file_input_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/list_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/contact_list_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
|
||||
|
|
|
@ -33,11 +33,12 @@ export type OwnProps = {
|
|||
readonly micCellEl?: HTMLElement;
|
||||
readonly attCellEl?: HTMLElement;
|
||||
readonly attachmentListEl?: HTMLElement;
|
||||
onChooseAttachment(): unknown;
|
||||
};
|
||||
|
||||
export type Props = Pick<
|
||||
CompositionInputProps,
|
||||
'onSubmit' | 'onEditorSizeChange' | 'onEditorStateChange'
|
||||
'onSubmit' | 'onEditorSizeChange' | 'onEditorStateChange' | 'startingText'
|
||||
> &
|
||||
Pick<
|
||||
EmojiButtonProps,
|
||||
|
@ -69,12 +70,13 @@ export const CompositionArea = ({
|
|||
i18n,
|
||||
attachmentListEl,
|
||||
micCellEl,
|
||||
attCellEl,
|
||||
onChooseAttachment,
|
||||
// CompositionInput
|
||||
onSubmit,
|
||||
compositionApi,
|
||||
onEditorSizeChange,
|
||||
onEditorStateChange,
|
||||
startingText,
|
||||
// EmojiButton
|
||||
onPickEmoji,
|
||||
onSetSkinTone,
|
||||
|
@ -94,7 +96,7 @@ export const CompositionArea = ({
|
|||
clearShowPickerHint,
|
||||
}: Props) => {
|
||||
const [disabled, setDisabled] = React.useState(false);
|
||||
const [showMic, setShowMic] = React.useState(true);
|
||||
const [showMic, setShowMic] = React.useState(!startingText);
|
||||
const [micActive, setMicActive] = React.useState(false);
|
||||
const [dirty, setDirty] = React.useState(false);
|
||||
const [large, setLarge] = React.useState(false);
|
||||
|
@ -179,23 +181,17 @@ export const CompositionArea = ({
|
|||
// The following is a work-around to allow react to lay-out backbone-managed
|
||||
// dom nodes until those functions are in React
|
||||
const micCellRef = React.useRef<HTMLDivElement>(null);
|
||||
const attCellRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useLayoutEffect(
|
||||
() => {
|
||||
const { current: micCellContainer } = micCellRef;
|
||||
const { current: attCellContainer } = attCellRef;
|
||||
if (micCellContainer && micCellEl) {
|
||||
emptyElement(micCellContainer);
|
||||
micCellContainer.appendChild(micCellEl);
|
||||
}
|
||||
if (attCellContainer && attCellEl) {
|
||||
emptyElement(attCellContainer);
|
||||
attCellContainer.appendChild(attCellEl);
|
||||
}
|
||||
|
||||
return noop;
|
||||
},
|
||||
[micCellRef, attCellRef, micCellEl, attCellEl, large, dirty, showMic]
|
||||
[micCellRef, micCellEl, large, dirty, showMic]
|
||||
);
|
||||
|
||||
React.useLayoutEffect(
|
||||
|
@ -235,8 +231,12 @@ export const CompositionArea = ({
|
|||
/>
|
||||
) : null;
|
||||
|
||||
const attButtonFragment = (
|
||||
<div className="module-composition-area__button-cell" ref={attCellRef} />
|
||||
const attButton = (
|
||||
<div className="module-composition-area__button-cell">
|
||||
<div className="choose-file">
|
||||
<button className="paperclip thumbnail" onClick={onChooseAttachment} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sendButtonFragment = (
|
||||
|
@ -318,13 +318,14 @@ export const CompositionArea = ({
|
|||
onEditorStateChange={onEditorStateChange}
|
||||
onDirtyChange={setDirty}
|
||||
skinTone={skinTone}
|
||||
startingText={startingText}
|
||||
/>
|
||||
</div>
|
||||
{!large ? (
|
||||
<>
|
||||
{stickerButtonFragment}
|
||||
{!dirty ? micButtonFragment : null}
|
||||
{attButtonFragment}
|
||||
{attButton}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
@ -337,7 +338,7 @@ export const CompositionArea = ({
|
|||
>
|
||||
{emojiButtonFragment}
|
||||
{stickerButtonFragment}
|
||||
{attButtonFragment}
|
||||
{attButton}
|
||||
{!dirty ? micButtonFragment : null}
|
||||
{dirty || !showMic ? sendButtonFragment : null}
|
||||
</div>
|
||||
|
|
|
@ -38,6 +38,7 @@ export type Props = {
|
|||
readonly editorRef?: React.RefObject<Editor>;
|
||||
readonly inputApi?: React.MutableRefObject<InputApi | undefined>;
|
||||
readonly skinTone?: EmojiPickDataType['skinTone'];
|
||||
readonly startingText?: string;
|
||||
onDirtyChange?(dirty: boolean): unknown;
|
||||
onEditorStateChange?(messageText: string, caretLocation: number): unknown;
|
||||
onEditorSizeChange?(rect: ContentRect): unknown;
|
||||
|
@ -141,6 +142,25 @@ const combineRefs = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
const getInitialEditorState = (startingText?: string) => {
|
||||
if (!startingText) {
|
||||
return EditorState.createEmpty(compositeDecorator);
|
||||
}
|
||||
|
||||
const end = startingText.length;
|
||||
const state = EditorState.createWithContent(
|
||||
ContentState.createFromText(startingText),
|
||||
compositeDecorator
|
||||
);
|
||||
const selection = state.getSelection();
|
||||
const selectionAtEnd = selection.merge({
|
||||
anchorOffset: end,
|
||||
focusOffset: end,
|
||||
}) as SelectionState;
|
||||
|
||||
return EditorState.forceSelection(state, selectionAtEnd);
|
||||
};
|
||||
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
export const CompositionInput = ({
|
||||
i18n,
|
||||
|
@ -154,9 +174,10 @@ export const CompositionInput = ({
|
|||
onPickEmoji,
|
||||
onSubmit,
|
||||
skinTone,
|
||||
startingText,
|
||||
}: Props) => {
|
||||
const [editorRenderState, setEditorRenderState] = React.useState(
|
||||
EditorState.createEmpty(compositeDecorator)
|
||||
getInitialEditorState(startingText)
|
||||
);
|
||||
const [searchText, setSearchText] = React.useState<string>('');
|
||||
const [emojiResults, setEmojiResults] = React.useState<Array<EmojiData>>([]);
|
||||
|
|
|
@ -23,6 +23,9 @@ export type PropsData = {
|
|||
unreadCount: number;
|
||||
isSelected: boolean;
|
||||
|
||||
draftPreview?: string;
|
||||
shouldShowDraft?: boolean;
|
||||
|
||||
typingContact?: Object;
|
||||
lastMessage?: {
|
||||
status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
|
||||
|
@ -134,11 +137,23 @@ export class ConversationListItem extends React.PureComponent<Props> {
|
|||
}
|
||||
|
||||
public renderMessage() {
|
||||
const { lastMessage, typingContact, unreadCount, i18n } = this.props;
|
||||
const {
|
||||
draftPreview,
|
||||
i18n,
|
||||
lastMessage,
|
||||
shouldShowDraft,
|
||||
typingContact,
|
||||
unreadCount,
|
||||
} = this.props;
|
||||
if (!lastMessage && !typingContact) {
|
||||
return null;
|
||||
}
|
||||
const text = lastMessage && lastMessage.text ? lastMessage.text : '';
|
||||
const text =
|
||||
shouldShowDraft && draftPreview
|
||||
? draftPreview
|
||||
: lastMessage && lastMessage.text
|
||||
? lastMessage.text
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="module-conversation-list-item__message">
|
||||
|
|
|
@ -85,6 +85,9 @@ export class AttachmentList extends React.Component<Props> {
|
|||
closeButton={true}
|
||||
onClick={clickCallback}
|
||||
onClickClose={onCloseAttachment}
|
||||
onError={() => {
|
||||
onCloseAttachment(attachment);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ type PropsActionsType = {
|
|||
loadOlderMessages: (messageId: string) => unknown;
|
||||
loadNewerMessages: (messageId: string) => unknown;
|
||||
loadNewestMessages: (messageId: string) => unknown;
|
||||
markMessageRead: (messageId: string) => unknown;
|
||||
markMessageRead: (messageId: string, forceFocus?: boolean) => unknown;
|
||||
} & MessageActionsType &
|
||||
SafetyNumberActionsType;
|
||||
|
||||
|
@ -397,7 +397,7 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
|
||||
// tslint:disable-next-line member-ordering cyclomatic-complexity
|
||||
public updateWithVisibleRows = debounce(
|
||||
() => {
|
||||
(forceFocus?: boolean) => {
|
||||
const {
|
||||
unreadCount,
|
||||
haveNewest,
|
||||
|
@ -421,7 +421,7 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
return;
|
||||
}
|
||||
|
||||
markMessageRead(newest.id);
|
||||
markMessageRead(newest.id, forceFocus);
|
||||
|
||||
const rowCount = this.getRowCount();
|
||||
|
||||
|
@ -699,6 +699,22 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
}
|
||||
};
|
||||
|
||||
public componentDidMount() {
|
||||
this.updateWithVisibleRows();
|
||||
// @ts-ignore
|
||||
window.registerForFocus(this.forceFocusVisibleRowUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
// @ts-ignore
|
||||
window.unregisterForFocus(this.forceFocusVisibleRowUpdate);
|
||||
}
|
||||
|
||||
public forceFocusVisibleRowUpdate = () => {
|
||||
const forceFocus = true;
|
||||
this.updateWithVisibleRows(forceFocus);
|
||||
};
|
||||
|
||||
public componentDidUpdate(prevProps: Props) {
|
||||
const {
|
||||
id,
|
||||
|
@ -732,8 +748,6 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
if (prevProps.items && prevProps.items.length > 0) {
|
||||
this.resizeAll();
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (!typingContact && prevProps.typingContact) {
|
||||
this.resizeAll();
|
||||
} else if (oldestUnreadIndex !== prevProps.oldestUnreadIndex) {
|
||||
|
@ -784,6 +798,8 @@ export class Timeline extends React.PureComponent<Props, State> {
|
|||
clearChangedMessages(id);
|
||||
} else if (this.resizeAllFlag) {
|
||||
this.resizeAll();
|
||||
} else {
|
||||
this.updateWithVisibleRows();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -63,6 +63,10 @@ export type ConversationType = {
|
|||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
};
|
||||
|
||||
shouldShowDraft?: boolean;
|
||||
draftText?: string;
|
||||
draftPreview?: string;
|
||||
};
|
||||
export type ConversationLookupType = {
|
||||
[key: string]: ConversationType;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { StateType } from '../reducer';
|
|||
|
||||
import { isShortName } from '../../components/emoji/lib';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
import {
|
||||
getBlessedStickerPacks,
|
||||
getInstalledStickerPacks,
|
||||
|
@ -16,12 +17,25 @@ import {
|
|||
getRecentStickers,
|
||||
} from '../selectors/stickers';
|
||||
|
||||
type ExternalProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const selectRecentEmojis = createSelector(
|
||||
({ emojis }: StateType) => emojis.recents,
|
||||
recents => recents.filter(isShortName)
|
||||
);
|
||||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||
const { id } = props;
|
||||
|
||||
const conversation = getConversationSelector(state)(id);
|
||||
if (!conversation) {
|
||||
throw new Error(`Conversation id ${id} not found!`);
|
||||
}
|
||||
|
||||
const { draftText } = conversation;
|
||||
|
||||
const receivedPacks = getReceivedStickerPacks(state);
|
||||
const installedPacks = getInstalledStickerPacks(state);
|
||||
const blessedPacks = getBlessedStickerPacks(state);
|
||||
|
@ -43,6 +57,7 @@ const mapStateToProps = (state: StateType) => {
|
|||
return {
|
||||
// Base
|
||||
i18n: getIntl(state),
|
||||
startingText: draftText,
|
||||
// Emojis
|
||||
recentEmojis,
|
||||
skinTone: get(state, ['items', 'skinTone'], 0),
|
||||
|
|
|
@ -7862,7 +7862,7 @@
|
|||
"rule": "DOM-innerHTML",
|
||||
"path": "ts/components/CompositionArea.tsx",
|
||||
"line": " el.innerHTML = '';",
|
||||
"lineNumber": 64,
|
||||
"lineNumber": 65,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-08-01T14:10:37.481Z",
|
||||
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
||||
|
|
|
@ -53,7 +53,6 @@ const excludedFiles = [
|
|||
'^js/models/messages.js',
|
||||
'^js/modules/crypto.js',
|
||||
'^js/views/conversation_view.js',
|
||||
'^js/views/file_input_view.js',
|
||||
'^js/background.js',
|
||||
|
||||
// Generated files
|
||||
|
|
Loading…
Reference in a new issue