This commit is contained in:
or-else
2020-01-21 16:18:19 +03:00
17 changed files with 1168 additions and 633 deletions
+24 -6
View File
@@ -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 */
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+3 -3
View File
@@ -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';
+51
View File
@@ -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>
);
}
};
+43 -23
View File
@@ -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} &times; {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} &times; {height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>}
</div> </div>
); );
} }
+62 -95
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+144 -42
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long