Persist drafts

This commit is contained in:
Scott Nonnenberg 2019-08-06 17:40:25 -07:00
parent 5ebd8bc690
commit 9d4f2afa5a
23 changed files with 1048 additions and 720 deletions

View File

@ -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"
}
}

View File

@ -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();

View File

@ -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);

View File

@ -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);
}

View File

@ -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>

View File

@ -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;

View File

@ -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),
]);
}

View File

@ -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

View File

@ -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
View File

@ -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 {

View File

@ -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,

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>>([]);

View File

@ -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">

View File

@ -85,6 +85,9 @@ export class AttachmentList extends React.Component<Props> {
closeButton={true}
onClick={clickCallback}
onClickClose={onCloseAttachment}
onError={() => {
onCloseAttachment(attachment);
}}
/>
);
}

View File

@ -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();
}
}

View File

@ -63,6 +63,10 @@ export type ConversationType = {
phoneNumber: string;
profileName?: string;
};
shouldShowDraft?: boolean;
draftText?: string;
draftPreview?: string;
};
export type ConversationLookupType = {
[key: string]: ConversationType;

View File

@ -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),

View File

@ -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"

View File

@ -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