file attachments work now

This commit is contained in:
or-else
2017-09-24 18:21:14 -07:00
parent a06d29863d
commit 3e8fd9d6e6
3 changed files with 179 additions and 22 deletions
+22 -4
View File
@@ -258,6 +258,10 @@ ul.menu > li.separator:hover {
color: #D32F2F; /* red 700 */ color: #D32F2F; /* red 700 */
} }
.gray {
color: #757575;
}
.hr { .hr {
display: block; display: block;
width: 100%; width: 100%;
@@ -518,13 +522,13 @@ table.permission-editor td.checkbox {
} }
.alert-box.error { .alert-box.error {
display: block; display: flex;
background-color: #ffcdd2; background-color: #ffcdd2;
border-bottom: 1px solid #ffbfc8; border-bottom: 1px solid #ffbfc8;
color: #b71c1c; color: #b71c1c;
} }
.alert-box.warning { .alert-box.warning {
display: block; display: flex;
background-color: #fff9c4; background-color: #fff9c4;
border-bottom: 1px solid #fff59d; border-bottom: 1px solid #fff59d;
color: #607d8b; color: #607d8b;
@@ -539,7 +543,7 @@ table.permission-editor td.checkbox {
color: #757575; color: #757575;
} }
.alert-box .icon { .alert-box .icon {
display: inline-block; display: block;
vertical-align: middle; vertical-align: middle;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
@@ -912,7 +916,7 @@ table.permission-editor td.checkbox {
} }
#send-message-panel .material-icons { #send-message-panel .material-icons {
min-width: 1.5rem; min-width: 1.75rem;
} }
#send-message-panel .material-icons.secondary, .material-icons.disabled { #send-message-panel .material-icons.secondary, .material-icons.disabled {
@@ -1103,6 +1107,17 @@ table.permission-editor td.checkbox {
color:#777; color:#777;
} }
/* Attachment styles */
.attachment {
display: flex;
}
.attachment .material-icons {
vertical-align: middle;
}
.attachment:last-of-type {
margin-bottom: -1rem;
}
/* END of talk bubbles */ /* END of talk bubbles */
/* END of topic/mesages view */ /* END of topic/mesages view */
@@ -1438,6 +1453,9 @@ ul.tabbar li.active > a:after {
.material-icons.small { .material-icons.small {
font-size: 1rem; font-size: 1rem;
} }
.material-icons.big {
font-size: 2.4rem;
}
#self-avatar.material-icons, .avatar-box .material-icons { #self-avatar.material-icons, .avatar-box .material-icons {
font-size: 2.4rem; font-size: 2.4rem;
+81 -11
View File
@@ -376,10 +376,16 @@ Sample JSON representation of the text above:
} }
return { return {
/** /**
* Parse text into structured representation. * Parse plain text into structured representation.
*/ */
parse: function(content) { parse: function(content) {
// Make sure we are parsing strings only.
if (typeof content != "string") {
return null;
}
// Split text into lines. It makes further processing easier. // Split text into lines. It makes further processing easier.
var lines = content.split(/\r?\n/); var lines = content.split(/\r?\n/);
@@ -491,11 +497,16 @@ Sample JSON representation of the text above:
* @param {string} fname file name suggestion for downloading the image. * @param {string} fname file name suggestion for downloading the image.
*/ */
insertImage: function(content, at, mime, base64bits, width, height, fname) { insertImage: function(content, at, mime, base64bits, width, height, fname) {
content = content || {txt: ""}; content = content || {txt: " "};
content.ent = content.ent || []; content.ent = content.ent || [];
content.fmt = content.fmt || []; content.fmt = content.fmt || [];
var entity = { content.fmt.push({
at: at,
len: 1,
key: content.ent.length
});
content.ent.push({
tp: "IM", tp: "IM",
data: { data: {
mime: mime, mime: mime,
@@ -504,15 +515,30 @@ Sample JSON representation of the text above:
height: height, height: height,
name: fname name: fname
} }
}; });
var range = {
at: at, return content;
},
attachFile: function(content, mime, base64bits, fname) {
content = content || {txt: ""};
content.ent = content.ent || [];
content.fmt = content.fmt || [];
content.fmt.push({
at: -1,
len: 0, len: 0,
key: content.ent.length key: content.ent.length
}; });
content.fmt.push(range); content.ent.push({
content.ent.push(entity); tp: "EX",
data: {
mime: mime,
val: base64bits,
name: fname
}
});
return content; return content;
}, },
@@ -577,6 +603,8 @@ Sample JSON representation of the text above:
format: function(content, formatter, context) { format: function(content, formatter, context) {
var {txt, fmt, ent} = content; var {txt, fmt, ent} = content;
txt = txt || "";
if (!fmt) { if (!fmt) {
return [txt]; return [txt];
} }
@@ -625,13 +653,56 @@ Sample JSON representation of the text above:
/** /**
* Returns true if content has no markup and no entities. * Returns true if content has no markup and no entities.
* *
* @param {drafty} content - content to check for presence of markup. * @param {Drafty} content - content to check for presence of markup.
* @returns true is content is plain text, false otherwise. * @returns true is content is plain text, false otherwise.
*/ */
isPlainText: function(content, noLineBreaks) { isPlainText: function(content, noLineBreaks) {
return !(content.fmt || content.ent); return !(content.fmt || content.ent);
}, },
/**
* Check if the drafty content has attachments.
*
* @param {Drafty} content - content to check for attachments.
* @returns true if there are attachments.
*/
hasAttachment: function(content) {
if (content.ent && content.ent.length > 0) {
for (var i in content.ent) {
if (content.ent[i].tp == "EX") {
return true;
}
}
}
return false;
},
/**
* Enumerate attachments
*
* @param {Drafty} content - drafty object to process for attachments.
* @param {function} callback - callback to call for each attachment.
* @param {Object} content - value of "this" for callback.
*/
attachments: function(content, callback, context) {
if (content.ent && content.ent.length > 0) {
for (var i in content.ent) {
if (content.ent[i].tp == "EX") {
callback.call(context, content.ent[i].data, i);
}
}
}
},
// Get blob URL for a given entity
getBlobUrl: function(entity) {
return base64toObjectUrl(entity.val, entity.mime);
},
getAttachmentSize: function(entity) {
// Value is base64 encoded. The actual object size is smaller than the encoded length.
return entity.val ? (entity.val.length * 0.75) | 0 : 0;
},
/** /**
* Get HTML tag for a given two-letter style name * Get HTML tag for a given two-letter style name
* @param {string} style - two-letter style, like ST or LN * @param {string} style - two-letter style, like ST or LN
@@ -664,7 +735,6 @@ Sample JSON representation of the text above:
getContentType: function() { getContentType: function() {
return "text/x-drafty"; return "text/x-drafty";
} }
}; };
}); });
+76 -7
View File
@@ -53,6 +53,10 @@
// Number of chat messages to fetch in one call. // Number of chat messages to fetch in one call.
var MESSAGES_PAGE = 24; var MESSAGES_PAGE = 24;
// Maximum allowed attachment size = 128K. Increade this limit to a
// greater value in production. Also increase max_message_size in server config.
var MAX_ATTACHMENT_SIZE = 1 << 17;
// Short representation of time in the past. // Short representation of time in the past.
function shortDateFormat(then) { function shortDateFormat(then) {
var locale = window.navigator.userLanguage || window.navigator.language; var locale = window.navigator.userLanguage || window.navigator.language;
@@ -144,6 +148,19 @@
return undefined; return undefined;
} }
// Convert a number of bytes to human-readable format.
function bytesToHumanSize(bytes) {
if(!bytes || bytes == 0) {
return '0 Bytes';
}
var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
var bucket = Math.floor(Math.log2(bytes) / 10) | 0;
var count = bytes / Math.pow(1024, bucket);
var round = bucket > 0 ? (count < 10 ? 2 : (count < 100 ? 1 : 0)) : 0;
return count.toFixed(round) + ' ' + sizes[bucket];
}
/* BEGIN Context Menu: popup/dropdown menu */ /* BEGIN Context Menu: popup/dropdown menu */
// Function is used by context menu to set permissions. // Function is used by context menu to set permissions.
@@ -2314,7 +2331,13 @@
(this.props.sequence === "single" || this.props.sequence === "last")); (this.props.sequence === "single" || this.props.sequence === "last"));
var content = this.props.content; var content = this.props.content;
var attachments = [];
if (this.props.mimeType === Drafty.getContentType()) { if (this.props.mimeType === Drafty.getContentType()) {
Drafty.attachments(content, function(att, i) {
attachments.push(<Message.Attachment
filename={att.name} blobUrl={Drafty.getBlobUrl(att)}
size={bytesToHumanSize(Drafty.getAttachmentSize(att))} key={i} />);
}, this);
content = React.createElement('span', null, Drafty.format(content, formatter)); content = React.createElement('span', null, Drafty.format(content, formatter));
} }
@@ -2331,6 +2354,7 @@
<div> <div>
<div className={bubbleClass}> <div className={bubbleClass}>
<p>{content} <p>{content}
{attachments}
<Message.ReceivedMarker response={this.props.response} <Message.ReceivedMarker response={this.props.response}
timestamp={this.props.timestamp} received={this.props.received} /> timestamp={this.props.timestamp} received={this.props.received} />
</p> </p>
@@ -2368,6 +2392,26 @@
} }
}); });
Message.Attachment = React.createClass({
render: function() {
var filename = this.props.filename || "file_attachment";
if (filename.length > 36) {
filename = filename.substring(0, 16) + "..." + filename.substring(-16);
}
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column">
<div>{filename} <span className="small">({this.props.size})</span></div>
<div><a href={this.props.blobUrl} download={this.props.filename}>
<i className="material-icons">file_download</i> save
</a></div>
</div>
</div>
);
}
});
var MessagesView = React.createClass({ var MessagesView = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
@@ -2694,7 +2738,8 @@
<MessagesView.Send <MessagesView.Send
topic={this.props.topic} topic={this.props.topic}
disabled={this.state.readOnly} disabled={this.state.readOnly}
sendMessage={this.props.sendMessage} /> sendMessage={this.props.sendMessage}
onError={this.props.onError} />
</div> </div>
); );
} else { } else {
@@ -2717,12 +2762,36 @@
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1 keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
}; };
}, },
handleAttachImage: function() {
handleAttachImage: function(e) {
if (e.target.files && e.target.files.length > 0) {
var file = e.target.files[0];
if (file.size > MAX_ATTACHMENT_SIZE) {
this.props.onError("The image size " + bytesToHumanSize(file.size) +
" exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err");
return;
}
}
}, },
handleAttachFile: function() {
handleAttachFile: function(e) {
if (e.target.files && e.target.files.length > 0) {
var file = e.target.files[0];
if (file.size > MAX_ATTACHMENT_SIZE) {
this.props.onError("The file size " + bytesToHumanSize(file.size) +
" exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " attachment limit.", "err");
return;
}
var reader = new FileReader();
var instance = this;
reader.addEventListener("load", function () {
instance.props.sendMessage(Drafty.attachFile(null, file.type, reader.result.split(',')[1], file.name));
}, false);
reader.readAsDataURL(file);
}
}, },
handleSend: function() { handleSend: function() {
var message = this.state.message.trim(); var message = this.state.message.trim();
if (message) { if (message) {
@@ -2762,12 +2831,12 @@
<div id="send-message-panel"> <div id="send-message-panel">
{this.props.disabled ? {this.props.disabled ?
<i className="material-icons disabled">photo</i> : <i className="material-icons disabled">photo</i> :
<a href="#" onClick={function(e) {instance.attachImage.click();}}> <a href="#" onClick={function(e) {instance.attachImage.click();}} title="Add image">
<i className="material-icons secondary">photo</i> <i className="material-icons secondary">photo</i>
</a>} </a>}
{this.props.disabled ? {this.props.disabled ?
<i className="material-icons disabled">attach_file</i> : <i className="material-icons disabled">attach_file</i> :
<a href="#" onClick={function(e) {instance.attachFile.click();}}> <a href="#" onClick={function(e) {instance.attachFile.click();}} title="Attach file">
<i className="material-icons secondary">attach_file</i> <i className="material-icons secondary">attach_file</i>
</a>} </a>}
<textarea id="sendMessage" placeholder={prompt} <textarea id="sendMessage" placeholder={prompt}
@@ -2775,7 +2844,7 @@
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress} /> onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress} />
{this.props.disabled ? {this.props.disabled ?
<i className="material-icons disabled">send</i> : <i className="material-icons disabled">send</i> :
<a href="#" onClick={this.handleSend}><i className="material-icons">send</i></a>} <a href="#" onClick={this.handleSend} title="Send"><i className="material-icons">send</i></a>}
<input type="file" ref={function(ref) {instance.attachFile = ref;}} <input type="file" ref={function(ref) {instance.attachFile = ref;}}
onChange={this.handleAttachFile} style={{display: 'none'}} /> onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={function(ref) {instance.attachImage = ref;}} accept="image/*" <input type="file" ref={function(ref) {instance.attachImage = ref;}} accept="image/*"
@@ -3088,7 +3157,7 @@
var topic = Tinode.getTopic(this.state.topicSelected); var topic = Tinode.getTopic(this.state.topicSelected);
var instance = this; var instance = this;
// Check if the message is formatted. // Check if the message is formatted.
var dft = Drafty.parse(msg); var dft = Drafty.parse(msg) || msg;
if (Drafty.isPlainText(dft)) { if (Drafty.isPlainText(dft)) {
// The message is simple text. Sned if as text/plain. // The message is simple text. Sned if as text/plain.
topic.publish(msg).catch(function(err) { topic.publish(msg).catch(function(err) {