inline images work now
This commit is contained in:
@@ -28,7 +28,7 @@ Background pattern from http://subtlepatterns.com/, commercial and non-commercia
|
||||
### Desktop screenshot
|
||||
|
||||
<p align="center">
|
||||
<img src="web-desktop-1.png" alt="Desktop web: full app" width=866 />
|
||||
<img src="web-desktop-2.png" alt="Desktop web: full app" width=866 />
|
||||
</p>
|
||||
|
||||
### Mobile screenshots
|
||||
|
||||
+61
-8
@@ -262,6 +262,10 @@ ul.menu > li.separator:hover {
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.hr {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -415,7 +419,6 @@ table.permission-editor td.checkbox {
|
||||
.permission-editor .material-icons {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.material-icons.clickable {
|
||||
@@ -544,7 +547,6 @@ table.permission-editor td.checkbox {
|
||||
}
|
||||
.alert-box .icon {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.alert-box .icon .material-icons {
|
||||
@@ -691,7 +693,6 @@ table.permission-editor td.checkbox {
|
||||
height: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5rem;
|
||||
vertical-align: middle;
|
||||
color: #0097A7;
|
||||
position: absolute;
|
||||
right: 0.05rem;
|
||||
@@ -943,6 +944,49 @@ table.permission-editor td.checkbox {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#image-preview {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background-color: rgba(255,255,255,0.90);
|
||||
}
|
||||
|
||||
#image-preview-caption-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: transparent;
|
||||
line-height: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 1rem 0.75rem 0 0.75rem;
|
||||
}
|
||||
|
||||
#image-preview-caption-panel a {
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#image-preview-caption-panel a:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#image-preview-container {
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#image-preview-footer {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
height: 3.2rem;
|
||||
line-height: 1.5rem;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Talk bubbles */
|
||||
.chat-box {
|
||||
list-style: none;
|
||||
@@ -1078,7 +1122,7 @@ table.permission-editor td.checkbox {
|
||||
|
||||
.chat-box .timestamp {
|
||||
display: inline-block;
|
||||
position:relative;
|
||||
position: relative;
|
||||
font-size: 80%;
|
||||
color: #777;
|
||||
padding-left: 1rem;
|
||||
@@ -1107,13 +1151,20 @@ table.permission-editor td.checkbox {
|
||||
color:#777;
|
||||
}
|
||||
|
||||
/* Inline image styles */
|
||||
.inline-image {
|
||||
max-width: 36rem;
|
||||
max-height: 24rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.1rem;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Attachment styles */
|
||||
.attachment {
|
||||
display: flex;
|
||||
}
|
||||
.attachment .material-icons {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.attachment:last-of-type {
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
@@ -1162,12 +1213,13 @@ table.permission-editor td.checkbox {
|
||||
.dialog-overlay {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background-color: rgba(0,0,0,0.25);
|
||||
background-color: rgba(255,255,255,0.90);
|
||||
}
|
||||
.modal-dialog {
|
||||
display: flex;
|
||||
@@ -1449,6 +1501,7 @@ ul.tabbar li.active > a:after {
|
||||
.material-icons {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.material-icons.small {
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -89,7 +89,7 @@ Sample JSON representation of the text above:
|
||||
LN: { name: 'a', isVoid: false },
|
||||
MN: { name: 'a', isVoid: false },
|
||||
HT: { name: 'a', isVoid: false },
|
||||
IM: { name: 'img', isVoid: true }
|
||||
IM: { name: ['a','img'], isVoid: true }
|
||||
};
|
||||
|
||||
// Convert base64-encoded string into Blob.
|
||||
@@ -141,12 +141,18 @@ Sample JSON representation of the text above:
|
||||
return (data.name ? '</a>' : '');
|
||||
},
|
||||
props: function(data) {
|
||||
return {
|
||||
src: base64toObjectUrl(data.val, data.mime),
|
||||
var url = base64toObjectUrl(data.val, data.mime);
|
||||
return [
|
||||
{
|
||||
download: data.name,
|
||||
href: url
|
||||
},
|
||||
{
|
||||
src: url,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
download: data.name
|
||||
};
|
||||
}
|
||||
];
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -703,6 +709,10 @@ Sample JSON representation of the text above:
|
||||
// Value is base64 encoded. The actual object size is smaller than the encoded length.
|
||||
return entity.val ? (entity.val.length * 0.75) | 0 : 0;
|
||||
},
|
||||
|
||||
getAttachmentType: function(entity) {
|
||||
return entity.mime || "text/plain";
|
||||
},
|
||||
/**
|
||||
* Get HTML tag for a given two-letter style name
|
||||
* @param {string} style - two-letter style, like ST or LN
|
||||
|
||||
+150
-42
@@ -57,6 +57,10 @@
|
||||
// greater value in production. Also increase max_message_size in server config.
|
||||
var MAX_ATTACHMENT_SIZE = 1 << 17;
|
||||
|
||||
// Maximum allowed linear dimension of an inline image in pixels. You may want
|
||||
// to adjust it to 1600 or 2400 for production.
|
||||
var MAX_IMAGE_SIZE = 768;
|
||||
|
||||
// Short representation of time in the past.
|
||||
function shortDateFormat(then) {
|
||||
var locale = window.navigator.userLanguage || window.navigator.language;
|
||||
@@ -79,6 +83,38 @@
|
||||
'data:image/' + photo.type + ';base64,' + photo.data : null;
|
||||
}
|
||||
|
||||
// Convert uploaded image into a base64-encoded string.
|
||||
function imageFileToBase64(file, callback) {
|
||||
var img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onload = function() {
|
||||
var width, height;
|
||||
if (this.height > this.width) {
|
||||
height = this.height;
|
||||
while(height > MAX_IMAGE_SIZE) {
|
||||
height = height >> 1;
|
||||
}
|
||||
width = (this.width * height / this.height) | 0;
|
||||
} else {
|
||||
width = this.width;
|
||||
while(width > MAX_IMAGE_SIZE) {
|
||||
width = width >> 1;
|
||||
}
|
||||
height = (this.height * width / this.width) | 0;
|
||||
}
|
||||
var canvas = document.createElement('CANVAS');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this, 0, 0, this.width, this.height, 0, 0, canvas.width, canvas.height);
|
||||
var imageBits = canvas.toDataURL("image/jpeg");
|
||||
canvas = null;
|
||||
callback(imageBits, width, height);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
// Make shortcut icon appear with a green dot.
|
||||
function updateFavicon(on) {
|
||||
var oldIcon = document.getElementById("shortcut-icon");
|
||||
@@ -2308,11 +2344,48 @@
|
||||
};
|
||||
|
||||
var Message = React.createClass({
|
||||
handlePreviewImage: function(e) {
|
||||
e.preventDefault();
|
||||
this.props.onImagePreview({
|
||||
url: e.target.src,
|
||||
filename: e.target.title,
|
||||
width: e.target.dataset.width,
|
||||
height: e.target.dataset.height,
|
||||
size: e.target.dataset.size,
|
||||
type: e.target.dataset.mime
|
||||
});
|
||||
},
|
||||
render: function() {
|
||||
var elementKey = 0;
|
||||
|
||||
var formatter = function(style, data, values) {
|
||||
elementKey += 1;
|
||||
if (style == "IM") {
|
||||
// Handle inline image
|
||||
var width = data.width, height = data.height;
|
||||
var style = null;
|
||||
if (width && height) {
|
||||
var scale = 1.0;
|
||||
if (data.width > REM_SIZE * 36) {
|
||||
scale = REM_SIZE * 36 / data.width;
|
||||
}
|
||||
if (data.height * scale > REM_SIZE * 24) {
|
||||
scale = REM_SIZE * 24 / data.height;
|
||||
}
|
||||
style = {
|
||||
width: Math.round(data.width * scale / REM_SIZE) + "rem",
|
||||
height: Math.round(data.height * scale / REM_SIZE) + "rem"
|
||||
}
|
||||
}
|
||||
var url = Drafty.getBlobUrl(data);
|
||||
return (
|
||||
<img className="inline-image" onClick={this.handlePreviewImage}
|
||||
data-width={data.width} data-height={data.height}
|
||||
data-size={Drafty.getAttachmentSize(data)}
|
||||
data-mime={Drafty.getAttachmentType(data)}
|
||||
src={url} style={style} alt="" title={data.name} />
|
||||
);
|
||||
} else {
|
||||
var el = Drafty.tagName(style);
|
||||
if (el) {
|
||||
var attr = Drafty.attrValue(style, data) || {};
|
||||
@@ -2321,6 +2394,7 @@
|
||||
} else {
|
||||
return values;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var sideClass = this.props.sequence + " " + (this.props.response ? "left" : "right");
|
||||
@@ -2338,7 +2412,7 @@
|
||||
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, this));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2402,7 +2476,7 @@
|
||||
<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>{filename} <span className="small gray">({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>
|
||||
@@ -2422,7 +2496,8 @@
|
||||
scrollPosition: 0,
|
||||
readOnly: false,
|
||||
writeOnly: false,
|
||||
typingIndicator: false
|
||||
typingIndicator: false,
|
||||
imagePreview: null
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2483,6 +2558,7 @@
|
||||
this.setState({
|
||||
messages: msgs,
|
||||
topic: props.topic,
|
||||
imagePreview: null,
|
||||
scrollPosition: 0
|
||||
});
|
||||
tryToResubscribe = true;
|
||||
@@ -2618,6 +2694,14 @@
|
||||
}
|
||||
},
|
||||
|
||||
handleImagePreview: function(content) {
|
||||
this.setState({ imagePreview: content });
|
||||
},
|
||||
|
||||
handleCloseImagePreview: function() {
|
||||
this.setState({ imagePreview: null });
|
||||
},
|
||||
|
||||
handleContextClick: function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -2678,7 +2762,8 @@
|
||||
<Message content={msg.content} mimeType={msg.head ? msg.head.mime : null}
|
||||
timestamp={msg.ts} response={isReply}
|
||||
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
||||
sequence={sequence} received={msgReceived} key={msg.seq} />
|
||||
sequence={sequence} received={msgReceived}
|
||||
onImagePreview={this.handleImagePreview} key={msg.seq} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2700,7 +2785,7 @@
|
||||
<div id="topic-caption-panel" className="caption-panel">
|
||||
{this.props.displayMobile ?
|
||||
<a href="#" id="hide-message-view" onClick={this.handleBackNavigation}>
|
||||
<i className="material-icons big">arrow_back</i>
|
||||
<i className="material-icons">arrow_back</i>
|
||||
</a>
|
||||
:
|
||||
null}
|
||||
@@ -2716,7 +2801,7 @@
|
||||
<div id="topic-users"></div>
|
||||
<div>
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons big">more_vert</i>
|
||||
<i className="material-icons">more_vert</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2740,6 +2825,9 @@
|
||||
disabled={this.state.readOnly}
|
||||
sendMessage={this.props.sendMessage}
|
||||
onError={this.props.onError} />
|
||||
{this.state.imagePreview ?
|
||||
<MessagesView.ImagePreview content={this.state.imagePreview}
|
||||
onClose={this.handleCloseImagePreview} /> : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -2766,11 +2854,18 @@
|
||||
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) +
|
||||
var instance = this;
|
||||
imageFileToBase64(file, function(bits, width, height) {
|
||||
if (bits.length > MAX_ATTACHMENT_SIZE) {
|
||||
this.props.onError("The image size " + bytesToHumanSize(bits.length) +
|
||||
" exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err");
|
||||
return;
|
||||
}
|
||||
|
||||
instance.props.sendMessage(Drafty.insertImage(null,
|
||||
0, file.type, bits.split(',')[1], width, height,
|
||||
file.name));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2785,7 +2880,7 @@
|
||||
|
||||
var reader = new FileReader();
|
||||
var instance = this;
|
||||
reader.addEventListener("load", function () {
|
||||
reader.addEventListener("load", function() {
|
||||
instance.props.sendMessage(Drafty.attachFile(null, file.type, reader.result.split(',')[1], file.name));
|
||||
}, false);
|
||||
reader.readAsDataURL(file);
|
||||
@@ -2872,45 +2967,63 @@
|
||||
);
|
||||
}
|
||||
});
|
||||
/* END Conversation panel */
|
||||
|
||||
/* This is a div which covers the entire AppView and shows selected modal dialog box */
|
||||
var ModalDialogWrapper = React.createClass({
|
||||
render: function() {
|
||||
var dialog = null;
|
||||
switch (this.props.dialog) {
|
||||
case "new-topic":
|
||||
dialog = <ModalDialogWrapper.NewTopic
|
||||
onOk={this.props.onOk}
|
||||
onCancel={this.props.onCancel} />;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (dialog) {
|
||||
dialog = <div className="dialog-overlay">{dialog}</div>;
|
||||
}
|
||||
return dialog;
|
||||
}
|
||||
MessagesView.ImagePreview = React.createClass({
|
||||
getInitialState: function() {
|
||||
return {};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.setState({
|
||||
width: this.container.clientWidth,
|
||||
height: this.container.clientHeight - REM_SIZE * 7
|
||||
});
|
||||
},
|
||||
|
||||
ModalDialogWrapper.NewTopic = React.createClass({
|
||||
render: function() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var instance = this;
|
||||
var width = null, height = null;
|
||||
console.log(this.props.content.width, this.props.content.height, this.state.width, this.state.height);
|
||||
if (this.props.content.width && this.props.content.height) {
|
||||
width = Math.max(Math.min(this.state.width, this.props.content.width), 8);
|
||||
height = Math.max(Math.min(this.state.height, this.props.content.height), 8);
|
||||
var scale = Math.min(width / this.props.content.width, height / this.props.content.height, 1.0);
|
||||
width = (this.props.content.width * scale) | 0;
|
||||
height = (this.props.content.height * scale) | 0;
|
||||
}
|
||||
var filename = this.props.content.filename;
|
||||
if (filename.length > 20) {
|
||||
filename = filename.slice(0, 8) + "..." + filename.slice(-8);
|
||||
}
|
||||
return (
|
||||
<div className="modal-dialog">
|
||||
<div className="title-panel">
|
||||
New topic
|
||||
<div id="image-preview" ref={function(ref) {instance.container = ref; }}
|
||||
onClick={this.props.onClose}>
|
||||
<div id="image-preview-caption-panel">
|
||||
<a href="#" download={this.props.content.filename}>
|
||||
<i className="material-icons">file_download</i> download
|
||||
</a>
|
||||
<a href="#" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container">
|
||||
<img src={this.props.content.url} width={width} height={height} />
|
||||
</div>
|
||||
<div id="image-preview-footer">
|
||||
<div><div><b>File name</b>:</div><div><span title={this.props.content.filename}>{filename}</span></div></div>
|
||||
<div><div><b>Content type</b>:</div><div>{this.props.content.type}</div></div>
|
||||
<div>
|
||||
<div><b>Size</b>:</div>
|
||||
<div>{this.props.content.width} × {this.props.content.height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
<div className="body-panel">Not implemented</div>
|
||||
<div className="dialog-buttons">
|
||||
<a href="#" onClick={this.props.onOk}>Yes</a>
|
||||
<a href="#" onClick={this.props.onCancel}>OK</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
/* End of modal dialog wrapper */
|
||||
/* END Conversation panel */
|
||||
|
||||
/* The top-level class to hold all fuinctionality together */
|
||||
var AppView = React.createClass({
|
||||
@@ -3385,11 +3498,6 @@
|
||||
render: function() {
|
||||
return (
|
||||
<div id="app-container">
|
||||
<ModalDialogWrapper
|
||||
dialog={this.state.dialogSelected}
|
||||
onOk={this.handleDialogCancel}
|
||||
onCancel={this.handleDialogCancel} />
|
||||
|
||||
<ContextMenu
|
||||
bounds={this.state.contextMenuBounds}
|
||||
clickAt={this.state.contextMenuClickAt}
|
||||
|
||||
Reference in New Issue
Block a user