merge
This commit is contained in:
+24
-6
@@ -181,9 +181,7 @@ textarea, input {
|
|||||||
outline: none;
|
outline: none;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
textarea {
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
@@ -234,6 +232,11 @@ input[type="password"].with-visibility {
|
|||||||
margin-right:-2rem;
|
margin-right:-2rem;
|
||||||
padding-right:2rem;
|
padding-right:2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: none;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
}
|
||||||
/* END of input and buttons */
|
/* END of input and buttons */
|
||||||
|
|
||||||
/* BEGIN scrollbar styling. */
|
/* BEGIN scrollbar styling. */
|
||||||
@@ -392,6 +395,10 @@ div.alert .content {
|
|||||||
inset 0 -0.25rem 0.25rem -0.25rem rgba(0,0,0,0.15);
|
inset 0 -0.25rem 0.25rem -0.25rem rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hr.thin {
|
||||||
|
height: 0.015rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Element which can take focus but shows no outline */
|
/* Element which can take focus but shows no outline */
|
||||||
.group-focus {
|
.group-focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -777,6 +784,10 @@ i.search {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-column.narrow {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.scrollable-panel {
|
.scrollable-panel {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
@@ -1279,10 +1290,12 @@ i.search {
|
|||||||
#image-preview-caption-panel {
|
#image-preview-caption-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background-color: transparent;
|
background-color: #eceff1;
|
||||||
|
box-shadow: 0 1px 2px silver; /* Faint shadow under the panel */
|
||||||
line-height: 2.5rem;
|
line-height: 2.5rem;
|
||||||
height: 3.5rem;
|
height: 4.6rem;
|
||||||
padding: 1rem 0.75rem 0 0.75rem;
|
padding: 1rem 0.75rem 0 0.75rem;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
#image-preview-caption-panel a {
|
#image-preview-caption-panel a {
|
||||||
@@ -1308,7 +1321,12 @@ i.search {
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
height: 3.2rem;
|
height: 3.2rem;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
background-color: transparent;
|
background-color: #eceff1;
|
||||||
|
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
|
||||||
|
}
|
||||||
|
|
||||||
|
#image-preview-container .material-icons {
|
||||||
|
font-size: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Talk bubbles */
|
/* Talk bubbles */
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -146,6 +146,7 @@
|
|||||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
||||||
"messaging_disabled_prompt": "Messaging disabled",
|
"messaging_disabled_prompt": "Messaging disabled",
|
||||||
"new_message_prompt": "New message",
|
"new_message_prompt": "New message",
|
||||||
|
"image_caption_prompt": "Image caption",
|
||||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
||||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
||||||
"tags_not_found": "No tags defined. Add some.",
|
"tags_not_found": "No tags defined. Add some.",
|
||||||
|
|||||||
+2
-1
@@ -150,5 +150,6 @@
|
|||||||
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
||||||
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
||||||
"search_no_results": "Ничего не найдено",
|
"search_no_results": "Ничего не найдено",
|
||||||
"deleted_content": "удалено"
|
"deleted_content": "удалено",
|
||||||
|
"image_caption_prompt": "Подпись к фото"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -150,5 +150,6 @@
|
|||||||
"topic_delete_warning": ":!![MISSING]!!:Are you sure you want to delete this conversation?",
|
"topic_delete_warning": ":!![MISSING]!!:Are you sure you want to delete this conversation?",
|
||||||
"topic_block_warning": ":!![MISSING]!!:Are you sure you want to block this conversation?",
|
"topic_block_warning": ":!![MISSING]!!:Are you sure you want to block this conversation?",
|
||||||
"search_no_results": ":!![MISSING]!!:Search returned no results",
|
"search_no_results": ":!![MISSING]!!:Search returned no results",
|
||||||
"deleted_content": "内容已删除"
|
"deleted_content": "内容已删除",
|
||||||
|
"image_caption_prompt": "图片标题"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -147,6 +147,7 @@
|
|||||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
||||||
"messaging_disabled_prompt": "Messaging disabled",
|
"messaging_disabled_prompt": "Messaging disabled",
|
||||||
"new_message_prompt": "New message",
|
"new_message_prompt": "New message",
|
||||||
|
"image_caption_prompt": "Image caption",
|
||||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
||||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
||||||
"tags_not_found": "No tags defined. Add some.",
|
"tags_not_found": "No tags defined. Add some.",
|
||||||
@@ -305,7 +306,8 @@
|
|||||||
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
||||||
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
||||||
"search_no_results": "Ничего не найдено",
|
"search_no_results": "Ничего не найдено",
|
||||||
"deleted_content": "удалено"
|
"deleted_content": "удалено",
|
||||||
|
"image_caption_prompt": "Подпись к фото"
|
||||||
},
|
},
|
||||||
"zh": {
|
"zh": {
|
||||||
"archived_contacts": "已归档联系人 ({count})",
|
"archived_contacts": "已归档联系人 ({count})",
|
||||||
@@ -450,6 +452,7 @@
|
|||||||
"action_delete_messages": "删除所有帖子",
|
"action_delete_messages": "删除所有帖子",
|
||||||
"action_block_contact": "屏蔽联系人",
|
"action_block_contact": "屏蔽联系人",
|
||||||
"action_report_chat": "检举垃圾邮件",
|
"action_report_chat": "检举垃圾邮件",
|
||||||
"deleted_content": "内容已删除"
|
"deleted_content": "内容已删除",
|
||||||
|
"image_caption_prompt": "图片标题"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+302
-164
@@ -5,6 +5,7 @@ import Tinode from 'tinode-sdk';
|
|||||||
const Drafty = Tinode.Drafty;
|
const Drafty = Tinode.Drafty;
|
||||||
|
|
||||||
import ChatMessage from '../widgets/chat-message.jsx';
|
import ChatMessage from '../widgets/chat-message.jsx';
|
||||||
|
import DocPreview from '../widgets/doc-preview.jsx';
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||||
import GroupSubs from '../widgets/group-subs.jsx';
|
import GroupSubs from '../widgets/group-subs.jsx';
|
||||||
import ImagePreview from '../widgets/image-preview.jsx';
|
import ImagePreview from '../widgets/image-preview.jsx';
|
||||||
@@ -14,9 +15,11 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
|
|||||||
import LogoView from './logo-view.jsx';
|
import LogoView from './logo-view.jsx';
|
||||||
import SendMessage from '../widgets/send-message.jsx';
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE } from '../config.js';
|
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE,
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE } from '../config.js';
|
||||||
import { shortDateFormat } from '../lib/strformat.js';
|
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64,
|
||||||
|
imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
online_now: {
|
online_now: {
|
||||||
@@ -61,6 +64,9 @@ class MessagesView extends React.Component {
|
|||||||
|
|
||||||
// this.propsChange = this.propsChange.bind(this);
|
// this.propsChange = this.propsChange.bind(this);
|
||||||
this.leave = this.leave.bind(this);
|
this.leave = this.leave.bind(this);
|
||||||
|
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
||||||
|
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
||||||
|
this.sendKeyPress = this.sendKeyPress.bind(this);
|
||||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
this.handleScrollReference = this.handleScrollReference.bind(this);
|
||||||
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
||||||
this.handleDescChange = this.handleDescChange.bind(this);
|
this.handleDescChange = this.handleDescChange.bind(this);
|
||||||
@@ -68,13 +74,15 @@ class MessagesView extends React.Component {
|
|||||||
this.handleNewMessage = this.handleNewMessage.bind(this);
|
this.handleNewMessage = this.handleNewMessage.bind(this);
|
||||||
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
|
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
|
||||||
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
|
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
|
||||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
this.handleImagePostview = this.handleImagePostview.bind(this);
|
||||||
this.handleCloseImagePreview = this.handleCloseImagePreview.bind(this);
|
this.handleClosePreview = this.handleClosePreview.bind(this);
|
||||||
this.handleFormResponse = this.handleFormResponse.bind(this);
|
this.handleFormResponse = this.handleFormResponse.bind(this);
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
this.handleContextClick = this.handleContextClick.bind(this);
|
||||||
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
|
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
|
||||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
||||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
||||||
|
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||||
|
this.handleAttachImage = this.handleAttachImage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -169,7 +177,9 @@ class MessagesView extends React.Component {
|
|||||||
topic: null,
|
topic: null,
|
||||||
title: '',
|
title: '',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
docPreview: null,
|
||||||
imagePreview: null,
|
imagePreview: null,
|
||||||
|
imagePostview: null,
|
||||||
typingIndicator: false,
|
typingIndicator: false,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false,
|
fetchingMessages: false,
|
||||||
@@ -179,7 +189,9 @@ class MessagesView extends React.Component {
|
|||||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||||
nextState = {
|
nextState = {
|
||||||
topic: nextProps.topic,
|
topic: nextProps.topic,
|
||||||
|
docPreview: null,
|
||||||
imagePreview: null,
|
imagePreview: null,
|
||||||
|
imagePostview: null,
|
||||||
typingIndicator: false,
|
typingIndicator: false,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false
|
fetchingMessages: false
|
||||||
@@ -432,12 +444,12 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImagePreview(content) {
|
handleImagePostview(content) {
|
||||||
this.setState({ imagePreview: content });
|
this.setState({ imagePostview: content });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCloseImagePreview() {
|
handleClosePreview() {
|
||||||
this.setState({ imagePreview: null });
|
this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFormResponse(action, text, data) {
|
handleFormResponse(action, text, data) {
|
||||||
@@ -492,170 +504,296 @@ class MessagesView extends React.Component {
|
|||||||
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
|
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendKeyPress() {
|
||||||
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
|
if (topic.isSubscribed()) {
|
||||||
|
topic.noteKeyPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFileAttachment(file) {
|
||||||
|
if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
|
||||||
|
// Too large to send inband - uploading out of band and sending as a link.
|
||||||
|
const uploader = this.props.tinode.getLargeFileHelper();
|
||||||
|
if (!uploader) {
|
||||||
|
this.props.onError(formatMessage(messages.cannot_initiate_upload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uploadCompletionPromise = uploader.upload(file);
|
||||||
|
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
|
||||||
|
// Pass data and the uploader to the TinodeWeb.
|
||||||
|
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||||
|
} else {
|
||||||
|
// Small enough to send inband.
|
||||||
|
fileToBase64(file,
|
||||||
|
(mime, bits, fname) => {
|
||||||
|
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
||||||
|
},
|
||||||
|
this.props.onError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAttachFile(file) {
|
||||||
|
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
|
||||||
|
// Too large.
|
||||||
|
this.props.onError(formatMessage(messages.file_attachment_too_large,
|
||||||
|
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
|
||||||
|
} else {
|
||||||
|
this.setState({ docPreview: {
|
||||||
|
file: file,
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendImageAttachment(caption, mime, bits, width, height, fname) {
|
||||||
|
let msg = Drafty.insertImage(null, 0, mime, bits, width, height, fname);
|
||||||
|
if (caption) {
|
||||||
|
msg = Drafty.appendLineBreak(msg);
|
||||||
|
msg = Drafty.append(msg, Drafty.init(caption));
|
||||||
|
}
|
||||||
|
this.props.sendMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAttachImage(file) {
|
||||||
|
// Check if the uploaded file is indeed an image and if it isn't too large.
|
||||||
|
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
||||||
|
// Convert image for size or format.
|
||||||
|
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
|
||||||
|
// Success
|
||||||
|
(bits, mime, width, height, fname) => {
|
||||||
|
this.setState({imagePreview: {
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
bits: bits,
|
||||||
|
filename: fname,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
size: bits.length,
|
||||||
|
type: mime
|
||||||
|
}});
|
||||||
|
},
|
||||||
|
// Failure
|
||||||
|
(err) => {
|
||||||
|
this.props.onError(err, 'err');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Image can be uploaded as is. No conversion is needed.
|
||||||
|
imageFileToBase64(file,
|
||||||
|
// Success
|
||||||
|
(bits, mime, width, height, fname) => {
|
||||||
|
this.setState({imagePreview: {
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
bits: bits,
|
||||||
|
filename: fname,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
size: bits.length,
|
||||||
|
type: mime
|
||||||
|
}});
|
||||||
|
},
|
||||||
|
// Failure
|
||||||
|
(err) => {
|
||||||
|
this.props.onError(err, 'err');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
|
|
||||||
let component;
|
let component;
|
||||||
if (this.state.topic) {
|
if (this.props.hideSelf) {
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
component = null;
|
||||||
const groupTopic = topic.getType() == 'grp';
|
} else if (!this.state.topic) {
|
||||||
let messageNodes = [];
|
|
||||||
let previousFrom = null;
|
|
||||||
let chatBoxClass = null;
|
|
||||||
for (let i=0; i<this.state.messages.length; i++) {
|
|
||||||
let msg = this.state.messages[i];
|
|
||||||
let nextFrom = null;
|
|
||||||
|
|
||||||
if (i + 1 < this.state.messages.length) {
|
|
||||||
nextFrom = this.state.messages[i+1].from
|
|
||||||
}
|
|
||||||
|
|
||||||
let sequence = 'single';
|
|
||||||
if (msg.from == previousFrom) {
|
|
||||||
if (msg.from == nextFrom) {
|
|
||||||
sequence = 'middle';
|
|
||||||
} else {
|
|
||||||
sequence = 'last';
|
|
||||||
}
|
|
||||||
} else if (msg.from == nextFrom) {
|
|
||||||
sequence = 'first';
|
|
||||||
}
|
|
||||||
previousFrom = msg.from;
|
|
||||||
|
|
||||||
const isReply = !(msg.from == this.props.myUserId);
|
|
||||||
const deliveryStatus = topic.msgStatus(msg);
|
|
||||||
|
|
||||||
let userName, userAvatar, userFrom;
|
|
||||||
if (groupTopic) {
|
|
||||||
const user = topic.userDesc(msg.from);
|
|
||||||
if (user && user.public) {
|
|
||||||
userName = user.public.fn;
|
|
||||||
userAvatar = makeImageUrl(user.public.photo);
|
|
||||||
}
|
|
||||||
userFrom = msg.from;
|
|
||||||
chatBoxClass='chat-box group';
|
|
||||||
} else {
|
|
||||||
chatBoxClass='chat-box';
|
|
||||||
}
|
|
||||||
|
|
||||||
messageNodes.push(
|
|
||||||
<ChatMessage
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
content={msg.content} deleted={msg.hi}
|
|
||||||
mimeType={msg.head ? msg.head.mime : null}
|
|
||||||
timestamp={msg.ts} response={isReply} seq={msg.seq}
|
|
||||||
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
|
||||||
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
|
|
||||||
viewportWidth={this.props.viewportWidth}
|
|
||||||
showContextMenu={this.handleShowContextMenuMessage}
|
|
||||||
onImagePreview={this.handleImagePreview}
|
|
||||||
onFormResponse={this.handleFormResponse}
|
|
||||||
onError={this.props.onError}
|
|
||||||
key={msg.seq} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSeen = null;
|
|
||||||
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
|
|
||||||
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
|
|
||||||
if (cont.online) {
|
|
||||||
lastSeen = formatMessage(messages.online_now);
|
|
||||||
} else if (cont.seen) {
|
|
||||||
lastSeen = formatMessage(messages.last_seen) + ": " +
|
|
||||||
shortDateFormat(cont.seen.when, this.props.intl.locale);
|
|
||||||
// TODO: also handle user agent in c.seen.ua
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const avatar = this.state.avatar || true;
|
|
||||||
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
|
||||||
|
|
||||||
component = (
|
|
||||||
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
||||||
<div id="topic-caption-panel" className="caption-panel">
|
|
||||||
{this.props.displayMobile ?
|
|
||||||
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
|
|
||||||
<i className="material-icons">arrow_back</i>
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null}
|
|
||||||
<div className="avatar-box">
|
|
||||||
<LetterTile
|
|
||||||
avatar={avatar}
|
|
||||||
topic={this.state.topic}
|
|
||||||
title={this.state.title} />
|
|
||||||
<span className={online} />
|
|
||||||
</div>
|
|
||||||
<div id="topic-title-group">
|
|
||||||
<div id="topic-title" className="panel-title">{
|
|
||||||
this.state.title ||
|
|
||||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
|
||||||
description="Title shown when the topic has no name" /></i>
|
|
||||||
}</div>
|
|
||||||
<div id="topic-last-seen">{lastSeen}</div>
|
|
||||||
</div>
|
|
||||||
{groupTopic ?
|
|
||||||
<GroupSubs
|
|
||||||
subscribers={this.state.onlineSubs} /> :
|
|
||||||
<div id="topic-users" />
|
|
||||||
}
|
|
||||||
<div>
|
|
||||||
<a href="#" onClick={this.handleContextClick}>
|
|
||||||
<i className="material-icons">more_vert</i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{this.props.displayMobile ?
|
|
||||||
<ErrorPanel
|
|
||||||
level={this.props.errorLevel}
|
|
||||||
text={this.props.errorText}
|
|
||||||
onClearError={this.props.onError} />
|
|
||||||
: null}
|
|
||||||
<LoadSpinner show={this.state.fetchingMessages} />
|
|
||||||
<div id="messages-container">
|
|
||||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
|
||||||
<ul id="scroller" className={chatBoxClass}>
|
|
||||||
{messageNodes}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{!this.state.isReader ?
|
|
||||||
<div id="write-only-background">
|
|
||||||
{this.state.readingBlocked ?
|
|
||||||
<div id="write-only-note">
|
|
||||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
|
||||||
description="Message shown in topic without the read access" />
|
|
||||||
</div>
|
|
||||||
: null }
|
|
||||||
</div>
|
|
||||||
: null }
|
|
||||||
</div>
|
|
||||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
|
||||||
<div id="peer-messaging-disabled-note">
|
|
||||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
|
||||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
|
||||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
|
||||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
|
||||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
|
||||||
</div> : null}
|
|
||||||
{this.state.unconfirmed ?
|
|
||||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
|
||||||
:
|
|
||||||
<SendMessage
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
topic={this.props.topic}
|
|
||||||
disabled={!this.state.isWriter}
|
|
||||||
sendMessage={this.props.sendMessage}
|
|
||||||
onError={this.props.onError} />}
|
|
||||||
{this.state.imagePreview ?
|
|
||||||
<ImagePreview content={this.state.imagePreview}
|
|
||||||
onClose={this.handleCloseImagePreview} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
component = (
|
component = (
|
||||||
<LogoView hideSelf={this.props.hideSelf}
|
<LogoView hideSelf={this.props.hideSelf}
|
||||||
serverVersion={this.props.serverVersion}
|
serverVersion={this.props.serverVersion}
|
||||||
serverAddress={this.props.serverAddress} />
|
serverAddress={this.props.serverAddress} />
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
let component2;
|
||||||
|
if (this.state.imagePreview) {
|
||||||
|
// Preview image before sending.
|
||||||
|
component2 = (
|
||||||
|
<ImagePreview
|
||||||
|
content={this.state.imagePreview}
|
||||||
|
onClose={this.handleClosePreview}
|
||||||
|
onSendMessage={this.sendImageAttachment} />
|
||||||
|
);
|
||||||
|
} else if (this.state.imagePostview) {
|
||||||
|
// Expand received image.
|
||||||
|
component2 = (
|
||||||
|
<ImagePreview
|
||||||
|
content={this.state.imagePostview}
|
||||||
|
onClose={this.handleClosePreview} />
|
||||||
|
);
|
||||||
|
} else if (this.state.docPreview) {
|
||||||
|
// Preview attachment before sending.
|
||||||
|
component2 = (
|
||||||
|
<DocPreview
|
||||||
|
content={this.state.docPreview}
|
||||||
|
onClose={this.handleClosePreview}
|
||||||
|
onSendMessage={this.sendFileAttachment} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
|
const groupTopic = topic.getType() == 'grp';
|
||||||
|
let messageNodes = [];
|
||||||
|
let previousFrom = null;
|
||||||
|
let chatBoxClass = null;
|
||||||
|
for (let i=0; i<this.state.messages.length; i++) {
|
||||||
|
let msg = this.state.messages[i];
|
||||||
|
let nextFrom = null;
|
||||||
|
|
||||||
|
if (i + 1 < this.state.messages.length) {
|
||||||
|
nextFrom = this.state.messages[i+1].from
|
||||||
|
}
|
||||||
|
|
||||||
|
let sequence = 'single';
|
||||||
|
if (msg.from == previousFrom) {
|
||||||
|
if (msg.from == nextFrom) {
|
||||||
|
sequence = 'middle';
|
||||||
|
} else {
|
||||||
|
sequence = 'last';
|
||||||
|
}
|
||||||
|
} else if (msg.from == nextFrom) {
|
||||||
|
sequence = 'first';
|
||||||
|
}
|
||||||
|
previousFrom = msg.from;
|
||||||
|
|
||||||
|
const isReply = !(msg.from == this.props.myUserId);
|
||||||
|
const deliveryStatus = topic.msgStatus(msg);
|
||||||
|
|
||||||
|
let userName, userAvatar, userFrom;
|
||||||
|
if (groupTopic) {
|
||||||
|
const user = topic.userDesc(msg.from);
|
||||||
|
if (user && user.public) {
|
||||||
|
userName = user.public.fn;
|
||||||
|
userAvatar = makeImageUrl(user.public.photo);
|
||||||
|
}
|
||||||
|
userFrom = msg.from;
|
||||||
|
chatBoxClass='chat-box group';
|
||||||
|
} else {
|
||||||
|
chatBoxClass='chat-box';
|
||||||
|
}
|
||||||
|
|
||||||
|
messageNodes.push(
|
||||||
|
<ChatMessage
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
content={msg.content} deleted={msg.hi}
|
||||||
|
mimeType={msg.head ? msg.head.mime : null}
|
||||||
|
timestamp={msg.ts} response={isReply} seq={msg.seq}
|
||||||
|
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
||||||
|
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
|
||||||
|
viewportWidth={this.props.viewportWidth}
|
||||||
|
showContextMenu={this.handleShowContextMenuMessage}
|
||||||
|
onImagePreview={this.handleImagePostview}
|
||||||
|
onFormResponse={this.handleFormResponse}
|
||||||
|
onError={this.props.onError}
|
||||||
|
key={msg.seq} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSeen = null;
|
||||||
|
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
|
||||||
|
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
|
||||||
|
if (cont.online) {
|
||||||
|
lastSeen = formatMessage(messages.online_now);
|
||||||
|
} else if (cont.seen) {
|
||||||
|
lastSeen = formatMessage(messages.last_seen) + ": " +
|
||||||
|
shortDateFormat(cont.seen.when, this.props.intl.locale);
|
||||||
|
// TODO: also handle user agent in c.seen.ua
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const avatar = this.state.avatar || true;
|
||||||
|
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
||||||
|
|
||||||
|
component2 = (
|
||||||
|
<>
|
||||||
|
<div id="topic-caption-panel" className="caption-panel">
|
||||||
|
{this.props.displayMobile ?
|
||||||
|
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
|
||||||
|
<i className="material-icons">arrow_back</i>
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
null}
|
||||||
|
<div className="avatar-box">
|
||||||
|
<LetterTile
|
||||||
|
avatar={avatar}
|
||||||
|
topic={this.state.topic}
|
||||||
|
title={this.state.title} />
|
||||||
|
<span className={online} />
|
||||||
|
</div>
|
||||||
|
<div id="topic-title-group">
|
||||||
|
<div id="topic-title" className="panel-title">{
|
||||||
|
this.state.title ||
|
||||||
|
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||||
|
description="Title shown when the topic has no name" /></i>
|
||||||
|
}</div>
|
||||||
|
<div id="topic-last-seen">{lastSeen}</div>
|
||||||
|
</div>
|
||||||
|
{groupTopic ?
|
||||||
|
<GroupSubs
|
||||||
|
subscribers={this.state.onlineSubs} /> :
|
||||||
|
<div id="topic-users" />
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<a href="#" onClick={this.handleContextClick}>
|
||||||
|
<i className="material-icons">more_vert</i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{this.props.displayMobile ?
|
||||||
|
<ErrorPanel
|
||||||
|
level={this.props.errorLevel}
|
||||||
|
text={this.props.errorText}
|
||||||
|
onClearError={this.props.onError} />
|
||||||
|
: null}
|
||||||
|
<LoadSpinner show={this.state.fetchingMessages} />
|
||||||
|
<div id="messages-container">
|
||||||
|
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||||
|
<ul id="scroller" className={chatBoxClass}>
|
||||||
|
{messageNodes}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{!this.state.isReader ?
|
||||||
|
<div id="write-only-background">
|
||||||
|
{this.state.readingBlocked ?
|
||||||
|
<div id="write-only-note">
|
||||||
|
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
||||||
|
description="Message shown in topic without the read access" />
|
||||||
|
</div>
|
||||||
|
: null }
|
||||||
|
</div>
|
||||||
|
: null }
|
||||||
|
</div>
|
||||||
|
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
||||||
|
<div id="peer-messaging-disabled-note">
|
||||||
|
<i className="material-icons secondary">block</i> <FormattedMessage
|
||||||
|
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
||||||
|
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
||||||
|
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
||||||
|
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
||||||
|
</div> : null}
|
||||||
|
{this.state.unconfirmed ?
|
||||||
|
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||||
|
:
|
||||||
|
<SendMessage
|
||||||
|
disabled={!this.state.isWriter}
|
||||||
|
onSendMessage={this.props.sendMessage}
|
||||||
|
onKeyPress={this.sendKeyPress}
|
||||||
|
onAttachFile={this.handleAttachFile}
|
||||||
|
onAttachImage={this.handleAttachImage}
|
||||||
|
onError={this.props.onError} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
component = <div id="topic-view">{component2}</div>
|
||||||
}
|
}
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export default class ChatMessage extends React.Component {
|
|||||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
props.uploader.onProgress = this.handleProgress.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handlePreviewImage = this.handlePreviewImage.bind(this);
|
this.handleImagePreview = this.handleImagePreview.bind(this);
|
||||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
this.handleContextClick = this.handleContextClick.bind(this);
|
||||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePreviewImage(e) {
|
handleImagePreview(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onImagePreview({
|
this.props.onImagePreview({
|
||||||
url: e.target.src,
|
url: e.target.src,
|
||||||
@@ -177,7 +177,7 @@ function draftyFormatter(style, data, values, key) {
|
|||||||
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
|
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
|
||||||
attr.src = sanitizeImageUrl(attr.src);
|
attr.src = sanitizeImageUrl(attr.src);
|
||||||
if (attr.src) {
|
if (attr.src) {
|
||||||
attr.onClick = this.handlePreviewImage;
|
attr.onClick = this.handleImagePreview;
|
||||||
attr.className += ' image-clickable';
|
attr.className += ' image-clickable';
|
||||||
} else {
|
} else {
|
||||||
attr.src = 'img/broken_image.png';
|
attr.src = 'img/broken_image.png';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
|
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||||
|
|
||||||
|
// Get material icon name from mime type.
|
||||||
|
function iconFromMime(mime) {
|
||||||
|
// If more icons become available in material icons, add them to this mime-to-icon mapping.
|
||||||
|
const mimeToIcon = {default: 'insert_drive_file', image: 'image', text: 'description', video: 'theaters'};
|
||||||
|
|
||||||
|
return mimeToIcon[mime] || mimeToIcon[(mime || '').split('/')[0]] || mimeToIcon['default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class DocPreview extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.handleSendDoc = this.handleSendDoc.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSendDoc(caption) {
|
||||||
|
this.props.onClose();
|
||||||
|
this.props.onSendMessage(this.props.content.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.props.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div id="image-preview">
|
||||||
|
<div id="image-preview-caption-panel">
|
||||||
|
<span>{this.props.content.filename}</span>
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||||
|
</div>
|
||||||
|
<div id="image-preview-container">
|
||||||
|
<div className="flex-column narrow">
|
||||||
|
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
|
||||||
|
<div><b><FormattedMessage id="label_content_type" /></b> {this.props.content.type || 'application/octet-stream'}</div>
|
||||||
|
<div><b><FormattedMessage id="label_size" /></b> {bytesToHumanSize(this.props.content.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SendMessage
|
||||||
|
noInput={true}
|
||||||
|
onSendMessage={this.handleSendDoc}
|
||||||
|
onError={this.props.onError} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import SendMessage from '../widgets/send-message.jsx';
|
||||||
|
|
||||||
import { REM_SIZE } from '../config.js';
|
import { REM_SIZE } from '../config.js';
|
||||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||||
@@ -13,6 +14,8 @@ export default class ImagePreview extends React.PureComponent {
|
|||||||
width: 0,
|
width: 0,
|
||||||
height: 0
|
height: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.handleSendImage = this.handleSendImage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignWidth(node) {
|
assignWidth(node) {
|
||||||
@@ -25,6 +28,12 @@ export default class ImagePreview extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSendImage(caption) {
|
||||||
|
this.props.onClose();
|
||||||
|
this.props.onSendMessage(caption, this.props.content.type, this.props.content.bits,
|
||||||
|
this.props.content.width, this.props.content.height, this.props.content.filename);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this.props.content) {
|
if (!this.props.content) {
|
||||||
return null;
|
return null;
|
||||||
@@ -41,39 +50,50 @@ export default class ImagePreview extends React.PureComponent {
|
|||||||
// Averate font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
// Averate font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
||||||
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
||||||
if (filename.length > maxlength) {
|
if (filename.length > maxlength) {
|
||||||
filename = filename.slice(0, maxlength/2 - 1) + '...' + filename.slice(1 - maxlength/2);
|
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
|
||||||
}
|
}
|
||||||
const width = this.props.content.width || '-';
|
const width = this.props.content.width || '-';
|
||||||
const height = this.props.content.height || '-';
|
const height = this.props.content.height || '-';
|
||||||
return (
|
return (
|
||||||
<div id="image-preview" onClick={this.props.onClose}>
|
<div id="image-preview">
|
||||||
<div id="image-preview-caption-panel">
|
<div id="image-preview-caption-panel">
|
||||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
{!this.props.onSendMessage ?
|
||||||
<i className="material-icons">file_download</i> <FormattedMessage
|
<a href={this.props.content.url} download={this.props.content.filename}>
|
||||||
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
<i className="material-icons">file_download</i> <FormattedMessage
|
||||||
</a>
|
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
<span>{this.props.content.filename}</span>
|
||||||
|
}
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||||
</div>
|
</div>
|
||||||
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
||||||
<img src={this.props.content.url} style={size} />
|
<img src={this.props.content.url} style={size} />
|
||||||
</div>
|
</div>
|
||||||
<div id="image-preview-footer">
|
{this.props.onSendMessage ?
|
||||||
<div>
|
<SendMessage
|
||||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
messagePrompt="add_image_caption"
|
||||||
description="Label for a file name" /></b></div>
|
acceptBlank={true}
|
||||||
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
onSendMessage={this.handleSendImage}
|
||||||
</div>
|
onError={this.props.onError} />
|
||||||
<div>
|
:
|
||||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
<div id="image-preview-footer">
|
||||||
description="Label for file content type (mime)" /></b></div>
|
<div>
|
||||||
<div>{this.props.content.type}</div>
|
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||||
</div>
|
description="Label for a file name" /></b></div>
|
||||||
<div>
|
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
||||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
</div>
|
||||||
description="Label for file size" /></b></div>
|
<div>
|
||||||
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||||
</div>
|
description="Label for file content type (mime)" /></b></div>
|
||||||
</div>
|
<div>{this.props.content.type}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||||
|
description="Label for file size" /></b></div>
|
||||||
|
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import React from 'react';
|
|||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
import { KEYPRESS_DELAY, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE } from '../config.js';
|
import { KEYPRESS_DELAY } from '../config.js';
|
||||||
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64, imageFileScaledToBase64 } from '../lib/blob-helpers.js';
|
import { filePasted } from '../lib/blob-helpers.js';
|
||||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
'messaging_disabled': {
|
'messaging_disabled': {
|
||||||
@@ -16,7 +15,12 @@ const messages = defineMessages({
|
|||||||
'type_new_message': {
|
'type_new_message': {
|
||||||
id: 'new_message_prompt',
|
id: 'new_message_prompt',
|
||||||
defaultMessage: 'New message',
|
defaultMessage: 'New message',
|
||||||
description: 'Prompt in SendMessage in read-only topic'
|
description: 'Prompt in send message field'
|
||||||
|
},
|
||||||
|
'add_image_caption': {
|
||||||
|
id: 'image_caption_prompt',
|
||||||
|
defaultMessage: 'Image caption',
|
||||||
|
description: 'Prompt in SendMessage for attached image'
|
||||||
},
|
},
|
||||||
'file_attachment_too_large': {
|
'file_attachment_too_large': {
|
||||||
id: 'file_attachment_too_large',
|
id: 'file_attachment_too_large',
|
||||||
@@ -49,15 +53,21 @@ class SendMessage extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
if (this.messageEditArea) {
|
||||||
|
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
|
if (this.messageEditArea) {
|
||||||
|
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate() {
|
||||||
this.messageEditArea.focus();
|
if (this.messageEditArea) {
|
||||||
|
this.messageEditArea.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePasteEvent(e) {
|
handlePasteEvent(e) {
|
||||||
@@ -67,11 +77,10 @@ class SendMessage extends React.PureComponent {
|
|||||||
// FIXME: handle large files too.
|
// FIXME: handle large files too.
|
||||||
if (filePasted(e,
|
if (filePasted(e,
|
||||||
(bits, mime, width, height, fname) => {
|
(bits, mime, width, height, fname) => {
|
||||||
this.props.sendMessage(Drafty.insertImage(null,
|
this.props.onAttachImage(mime, bits, width, height, fname);
|
||||||
0, mime, bits, width, height, fname));
|
|
||||||
},
|
},
|
||||||
(mime, bits, fname) => {
|
(mime, bits, fname) => {
|
||||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
this.props.onAttachFile(mime, bits, fname);
|
||||||
},
|
},
|
||||||
this.props.onError)) {
|
this.props.onError)) {
|
||||||
|
|
||||||
@@ -82,34 +91,7 @@ class SendMessage extends React.PureComponent {
|
|||||||
|
|
||||||
handleAttachImage(e) {
|
handleAttachImage(e) {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const file = e.target.files[0];
|
this.props.onAttachImage(e.target.files[0]);
|
||||||
// Check if the uploaded file is indeed an image and if it isn't too large.
|
|
||||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
|
||||||
// Convert image for size or format.
|
|
||||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
|
|
||||||
// Success
|
|
||||||
(bits, mime, width, height, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.insertImage(null,
|
|
||||||
0, mime, bits, width, height, fname));
|
|
||||||
},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Image can be uploaded as is. No conversion is needed.
|
|
||||||
imageFileToBase64(file,
|
|
||||||
// Success
|
|
||||||
(bits, mime, width, height, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.insertImage(null,
|
|
||||||
0, mime, bits, width, height, fname));
|
|
||||||
},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Clear the value so the same file can be uploaded again.
|
// Clear the value so the same file can be uploaded again.
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -118,32 +100,7 @@ class SendMessage extends React.PureComponent {
|
|||||||
handleAttachFile(e) {
|
handleAttachFile(e) {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const file = e.target.files[0];
|
this.props.onAttachFile(e.target.files[0]);
|
||||||
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
|
|
||||||
// Too large.
|
|
||||||
this.props.onError(formatMessage(messages.file_attachment_too_large,
|
|
||||||
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
|
|
||||||
} else if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
|
|
||||||
// Too large to send inband - uploading out of band and sending as a link.
|
|
||||||
const uploader = this.props.tinode.getLargeFileHelper();
|
|
||||||
if (!uploader) {
|
|
||||||
this.props.onError(formatMessage(messages.cannot_initiate_upload));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Format data and initiate upload.
|
|
||||||
const uploadCompletionPromise = uploader.upload(file);
|
|
||||||
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
|
|
||||||
// Pass data and the uploader to the TinodeWeb.
|
|
||||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
|
||||||
} else {
|
|
||||||
// Small enough to send inband.
|
|
||||||
fileToBase64(file,
|
|
||||||
(mime, bits, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
|
||||||
},
|
|
||||||
this.props.onError
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Clear the value so the same file can be uploaded again.
|
// Clear the value so the same file can be uploaded again.
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -152,8 +109,8 @@ class SendMessage extends React.PureComponent {
|
|||||||
handleSend(e) {
|
handleSend(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const message = this.state.message.trim();
|
const message = this.state.message.trim();
|
||||||
if (message) {
|
if (message || this.props.acceptBlank || this.props.noInput) {
|
||||||
this.props.sendMessage(this.state.message.trim());
|
this.props.onSendMessage(message);
|
||||||
this.setState({message: ''});
|
this.setState({message: ''});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,13 +131,12 @@ class SendMessage extends React.PureComponent {
|
|||||||
|
|
||||||
handleMessageTyping(e) {
|
handleMessageTyping(e) {
|
||||||
const newState = {message: e.target.value};
|
const newState = {message: e.target.value};
|
||||||
const now = new Date().getTime();
|
if (this.props.onKeyPress) {
|
||||||
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
const now = new Date().getTime();
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
||||||
if (topic.isSubscribed()) {
|
this.props.onKeyPress();
|
||||||
topic.noteKeyPress();
|
newState.keypressTimestamp = now;
|
||||||
}
|
}
|
||||||
newState.keypressTimestamp = now;
|
|
||||||
}
|
}
|
||||||
this.setState(newState);
|
this.setState(newState);
|
||||||
}
|
}
|
||||||
@@ -189,33 +145,44 @@ class SendMessage extends React.PureComponent {
|
|||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const prompt = this.props.disabled ?
|
const prompt = this.props.disabled ?
|
||||||
formatMessage(messages.messaging_disabled) :
|
formatMessage(messages.messaging_disabled) :
|
||||||
formatMessage(messages.type_new_message);
|
(this.props.messagePrompt ?
|
||||||
|
formatMessage(messages[this.props.messagePrompt]) :
|
||||||
|
formatMessage(messages.type_new_message));
|
||||||
return (
|
return (
|
||||||
<div id="send-message-panel">
|
<div id="send-message-panel">
|
||||||
|
{this.props.onAttachFile ?
|
||||||
|
(this.props.disabled ?
|
||||||
|
<>
|
||||||
|
<i className="material-icons disabled">photo</i>
|
||||||
|
<i className="material-icons disabled">attach_file</i>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
||||||
|
<i className="material-icons secondary">photo</i>
|
||||||
|
</a>
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
||||||
|
<i className="material-icons secondary">attach_file</i>
|
||||||
|
</a>
|
||||||
|
</>)
|
||||||
|
:
|
||||||
|
null}
|
||||||
|
{this.props.noInput ?
|
||||||
|
<div className="hr thin" /> :
|
||||||
|
<textarea id="sendMessage" placeholder={prompt}
|
||||||
|
disabled={this.props.disabled} value={this.state.message}
|
||||||
|
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
|
||||||
|
ref={(ref) => {this.messageEditArea = ref;}}
|
||||||
|
autoFocus />}
|
||||||
{this.props.disabled ?
|
{this.props.disabled ?
|
||||||
<i className="material-icons disabled">photo</i> :
|
<i className="material-icons disabled">send</i> :
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
<a href="#" onClick={this.handleSend} title="Send">
|
||||||
<i className="material-icons secondary">photo</i>
|
<i className="material-icons">send</i>
|
||||||
</a>}
|
</a>}
|
||||||
{this.props.disabled ?
|
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
||||||
<i className="material-icons disabled">attach_file</i> :
|
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
||||||
<i className="material-icons secondary">attach_file</i>
|
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||||
</a>}
|
|
||||||
<textarea id="sendMessage" placeholder={prompt}
|
|
||||||
disabled={this.props.disabled} value={this.state.message}
|
|
||||||
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
|
|
||||||
ref={(ref) => {this.messageEditArea = ref;}}
|
|
||||||
autoFocus />
|
|
||||||
{this.props.disabled ?
|
|
||||||
<i className="material-icons disabled">send</i> :
|
|
||||||
<a href="#" onClick={this.handleSend} title="Send">
|
|
||||||
<i className="material-icons">send</i>
|
|
||||||
</a>}
|
|
||||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
|
||||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
|
||||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
|
||||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+524
-291
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+144
-42
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user