Merge pull request #84 from tinode/next-reply
Reply feature draft vs "next"
This commit is contained in:
+127
-1
@@ -1380,7 +1380,6 @@ img.channel {
|
|||||||
flex: none;
|
flex: none;
|
||||||
padding: 0.4rem 0.4rem 0.4rem 0.6rem;
|
padding: 0.4rem 0.4rem 0.4rem 0.6rem;
|
||||||
background-color: #eceff1;
|
background-color: #eceff1;
|
||||||
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
|
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1390,12 @@ img.channel {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#send-message-wrapper {
|
||||||
|
background-color: #eceff1;
|
||||||
|
box-shadow: 0px -2px 3px -1px rgba(0,0,0,0.1); /* Faint shadow above panel */
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
#sendMessage {
|
#sendMessage {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -1528,6 +1533,7 @@ img.channel {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
transition: background-color 1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-box .right {
|
.chat-box .right {
|
||||||
@@ -2150,6 +2156,106 @@ ul.tabbar li.active > a {
|
|||||||
color: #D84315!important;
|
color: #D84315!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colors of user mentions */
|
||||||
|
.mn-light-color0 {
|
||||||
|
color: #ef9a9a;
|
||||||
|
}
|
||||||
|
.mn-light-color1 {
|
||||||
|
color: #90caf9;
|
||||||
|
}
|
||||||
|
.mn-light-color2 {
|
||||||
|
color: #b0bec5;
|
||||||
|
}
|
||||||
|
.mn-light-color3 {
|
||||||
|
color: #b39ddb;
|
||||||
|
}
|
||||||
|
.mn-light-color4 {
|
||||||
|
color: #ffab91;
|
||||||
|
}
|
||||||
|
.mn-light-color5 {
|
||||||
|
color: #a5d6a7;
|
||||||
|
}
|
||||||
|
.mn-light-color6 {
|
||||||
|
color: #eeeeee;
|
||||||
|
}
|
||||||
|
.mn-light-color7 {
|
||||||
|
color: #e6ee9c;
|
||||||
|
}
|
||||||
|
.mn-light-color8 {
|
||||||
|
color: #c5e1a5;
|
||||||
|
}
|
||||||
|
.mn-light-color9 {
|
||||||
|
color: #f0e57d;
|
||||||
|
}
|
||||||
|
.mn-light-color10 {
|
||||||
|
color: #f48fb1;
|
||||||
|
}
|
||||||
|
.mn-light-color11 {
|
||||||
|
color: #9fa8da;
|
||||||
|
}
|
||||||
|
.mn-light-color12 {
|
||||||
|
color: #ffe082;
|
||||||
|
}
|
||||||
|
.mn-light-color13 {
|
||||||
|
color: #bcaaa4;
|
||||||
|
}
|
||||||
|
.mn-light-color14 {
|
||||||
|
color: #80deea;
|
||||||
|
}
|
||||||
|
.mn-light-color15 {
|
||||||
|
color: #ce93d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Darker version of user mention colors */
|
||||||
|
.mn-dark-color0 {
|
||||||
|
color: #C62828;
|
||||||
|
}
|
||||||
|
.mn-dark-color1 {
|
||||||
|
color: #AD1457;
|
||||||
|
}
|
||||||
|
.mn-dark-color2 {
|
||||||
|
color: #6A1B9A;
|
||||||
|
}
|
||||||
|
.mn-dark-color3 {
|
||||||
|
color: #4527A0;
|
||||||
|
}
|
||||||
|
.mn-dark-color4 {
|
||||||
|
color: #283593;
|
||||||
|
}
|
||||||
|
.mn-dark-color5 {
|
||||||
|
color: #1565C0;
|
||||||
|
}
|
||||||
|
.mn-dark-color6 {
|
||||||
|
color: #0277BD;
|
||||||
|
}
|
||||||
|
.mn-dark-color7 {
|
||||||
|
color: #00838F;
|
||||||
|
}
|
||||||
|
.mn-dark-color8 {
|
||||||
|
color: #00695C;
|
||||||
|
}
|
||||||
|
.mn-dark-color9 {
|
||||||
|
color: #2E7D32;
|
||||||
|
}
|
||||||
|
.mn-dark-color10 {
|
||||||
|
color: #558B2F;
|
||||||
|
}
|
||||||
|
.mn-dark-color11 {
|
||||||
|
color: #9E9D24;
|
||||||
|
}
|
||||||
|
.mn-dark-color12 {
|
||||||
|
color: #F9A825;
|
||||||
|
}
|
||||||
|
.mn-dark-color13 {
|
||||||
|
color: #FF8F00;
|
||||||
|
}
|
||||||
|
.mn-dark-color14 {
|
||||||
|
color: #EF6C00;
|
||||||
|
}
|
||||||
|
.mn-dark-color15 {
|
||||||
|
color: #D84315;
|
||||||
|
}
|
||||||
|
|
||||||
/* Material icons resized from the default 24pt */
|
/* Material icons resized from the default 24pt */
|
||||||
.material-icons {
|
.material-icons {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@@ -2449,3 +2555,23 @@ ul.tabbar li.active > a {
|
|||||||
.cropper .zoom:focus {
|
.cropper .zoom:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Blue quote bar left of the quoted (cited) text */
|
||||||
|
.reply-quote {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: solid #039be5;
|
||||||
|
line-height: 1.5em;
|
||||||
|
font-weight: 100;
|
||||||
|
margin-left: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-quote-header {
|
||||||
|
color: #039be5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-quote-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -78,6 +78,9 @@ export const MAX_IMAGE_DIM = 1024;
|
|||||||
// Linear dimensions of image preview: shrink image under this size for previews.
|
// Linear dimensions of image preview: shrink image under this size for previews.
|
||||||
export const IMAGE_PREVIEW_DIM = 64;
|
export const IMAGE_PREVIEW_DIM = 64;
|
||||||
|
|
||||||
|
// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.
|
||||||
|
export const IMAGE_THUMBNAIL_DIM = 25;
|
||||||
|
|
||||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
// Maximum number of online users to be shown in a topic title bar. Others will be
|
||||||
// hidden under "+X more"
|
// hidden under "+X more"
|
||||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,6 +760,13 @@
|
|||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
},
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "Reply",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": false,
|
||||||
|
"obsolete": false
|
||||||
|
},
|
||||||
"menu_item_restore_topic": {
|
"menu_item_restore_topic": {
|
||||||
"translation": "Restore",
|
"translation": "Restore",
|
||||||
"defaultMessage": "Restore",
|
"defaultMessage": "Restore",
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,6 +441,10 @@
|
|||||||
"defaultMessage": "Mute",
|
"defaultMessage": "Mute",
|
||||||
"description": "Turn off notifications"
|
"description": "Turn off notifications"
|
||||||
},
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message"
|
||||||
|
},
|
||||||
"menu_item_restore_topic": {
|
"menu_item_restore_topic": {
|
||||||
"defaultMessage": "Restore",
|
"defaultMessage": "Restore",
|
||||||
"description": "Restore topic from archive"
|
"description": "Restore topic from archive"
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1326,5 +1326,12 @@
|
|||||||
"description": "Label for unarchiving the topic",
|
"description": "Label for unarchiving the topic",
|
||||||
"missing": false,
|
"missing": false,
|
||||||
"obsolete": false
|
"obsolete": false
|
||||||
|
},
|
||||||
|
"menu_item_reply": {
|
||||||
|
"translation": "",
|
||||||
|
"defaultMessage": "Reply",
|
||||||
|
"description": "Reply to message",
|
||||||
|
"missing": true,
|
||||||
|
"obsolete": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,3 +244,27 @@ export function base64ReEncode(str) {
|
|||||||
}
|
}
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a base64 encoded string with the provided mime type into a Blob.
|
||||||
|
export function base64ToBlob(str, mime) {
|
||||||
|
if (!str) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make blob.
|
||||||
|
const bin = atob(str);
|
||||||
|
const length = bin.length;
|
||||||
|
const buf = new ArrayBuffer(length);
|
||||||
|
const arr = new Uint8Array(buf);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
arr[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buf], { type: mime });
|
||||||
|
} catch(err) {
|
||||||
|
console.log("Failed to convert base64 to blob: ", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ function stringToColorHash(value) {
|
|||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User avatar letter color id.
|
||||||
|
export function letterTileColorId(userId) {
|
||||||
|
return Math.abs(stringToColorHash(userId)) % 16;
|
||||||
|
}
|
||||||
|
|
||||||
// Converts user or topic ID to a CSS color class. Ideally it should produce the same color value as Java version.
|
// Converts user or topic ID to a CSS color class. Ideally it should produce the same color value as Java version.
|
||||||
// params:
|
// params:
|
||||||
// - {string} id: user or topic ID
|
// - {string} id: user or topic ID
|
||||||
// - {boolean} light: light or dark version of the color.
|
// - {boolean} light: light or dark version of the color.
|
||||||
// - {boolean} fg: foreground or background color.
|
// - {boolean} fg: foreground or background color.
|
||||||
export function idToColorClass(id, light, fg) {
|
export function idToColorClass(id, light, fg) {
|
||||||
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + (Math.abs(stringToColorHash(id)) % 16);
|
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + letterTileColorId(id);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+393
-17
@@ -15,13 +15,16 @@ import LetterTile from '../widgets/letter-tile.jsx';
|
|||||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
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 UploadingImage from '../widgets/uploading-image.jsx'
|
||||||
|
|
||||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
|
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
|
||||||
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
|
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY,
|
||||||
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
|
IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||||
imageScaled, makeImageUrl } from '../lib/blob-helpers.js';
|
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, base64ToBlob, filePasted, fileToBase64,
|
||||||
|
imageScaled, makeImageUrl, fitImageSize } from '../lib/blob-helpers.js';
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
import { bytesToHumanSize, shortDateFormat, letterTileColorId } from '../lib/strformat.js';
|
||||||
|
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
// Run timer with this frequency (ms) for checking notification queue.
|
// Run timer with this frequency (ms) for checking notification queue.
|
||||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||||
@@ -51,7 +54,18 @@ const messages = defineMessages({
|
|||||||
id: 'file_attachment_too_large',
|
id: 'file_attachment_too_large',
|
||||||
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
||||||
description: 'Error message when attachment is too large'
|
description: 'Error message when attachment is too large'
|
||||||
}
|
},
|
||||||
|
|
||||||
|
drafty_form: {
|
||||||
|
id: 'drafty_form',
|
||||||
|
defaultMessage: 'Form: ',
|
||||||
|
description: 'Comment for form in drafty preview'
|
||||||
|
},
|
||||||
|
drafty_attachment: {
|
||||||
|
id: 'drafty_attachment',
|
||||||
|
defaultMessage: 'Attachment',
|
||||||
|
description: 'Comment for attachment in drafty preview'
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checks if the access permissions are granted but not yet accepted.
|
// Checks if the access permissions are granted but not yet accepted.
|
||||||
@@ -103,10 +117,26 @@ class MessagesView extends React.Component {
|
|||||||
this.postReadNotification = this.postReadNotification.bind(this);
|
this.postReadNotification = this.postReadNotification.bind(this);
|
||||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||||
|
|
||||||
|
this.handlePickReply = this.handlePickReply.bind(this);
|
||||||
|
this.handleCancelReply = this.handleCancelReply.bind(this);
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
|
|
||||||
|
this.chatMessageRefs = {};
|
||||||
|
this.getOrCreateMessageRef = this.getOrCreateMessageRef.bind(this);
|
||||||
|
|
||||||
this.readNotificationQueue = [];
|
this.readNotificationQueue = [];
|
||||||
this.readNotificationTimer = null;
|
this.readNotificationTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrCreateMessageRef(seqId) {
|
||||||
|
if (this.chatMessageRefs.hasOwnProperty(seqId)) {
|
||||||
|
return this.chatMessageRefs[seqId];
|
||||||
|
}
|
||||||
|
const ref = React.createRef();
|
||||||
|
this.chatMessageRefs[seqId] = ref;
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// this.propsChange(this.props, this.state);
|
// this.propsChange(this.props, this.state);
|
||||||
if (this.messagesScroller) {
|
if (this.messagesScroller) {
|
||||||
@@ -186,7 +216,8 @@ class MessagesView extends React.Component {
|
|||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false,
|
fetchingMessages: false,
|
||||||
peerMessagingDisabled: false,
|
peerMessagingDisabled: false,
|
||||||
channel: false
|
channel: false,
|
||||||
|
reply: null
|
||||||
};
|
};
|
||||||
} else if (nextProps.topic != prevState.topic) {
|
} else if (nextProps.topic != prevState.topic) {
|
||||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||||
@@ -197,7 +228,8 @@ class MessagesView extends React.Component {
|
|||||||
imagePostview: null,
|
imagePostview: null,
|
||||||
typingIndicator: false,
|
typingIndicator: false,
|
||||||
scrollPosition: 0,
|
scrollPosition: 0,
|
||||||
fetchingMessages: false
|
fetchingMessages: false,
|
||||||
|
reply: null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (topic) {
|
if (topic) {
|
||||||
@@ -637,6 +669,21 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendMessage sends the message with an optional subscription to topic first.
|
||||||
|
sendMessage(msg, uploadCompletionPromise, uploader) {
|
||||||
|
let head;
|
||||||
|
if (this.state.reply && this.state.reply.content) {
|
||||||
|
head = {reply: '' + this.state.reply.seq};
|
||||||
|
// Turn it into Drafty so we can make a quoted Drafty object later.
|
||||||
|
if (typeof msg == 'string') {
|
||||||
|
msg = Drafty.parse(msg);
|
||||||
|
}
|
||||||
|
msg = Drafty.attachQuote(msg, this.state.reply.content);
|
||||||
|
this.setState({reply: null});
|
||||||
|
}
|
||||||
|
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||||
|
}
|
||||||
|
|
||||||
// Send attachment as Drafty message:
|
// Send attachment as Drafty message:
|
||||||
// - if file is too large, upload it and send a s link.
|
// - if file is too large, upload it and send a s link.
|
||||||
// - if file is small enough, just send it in-band.
|
// - if file is small enough, just send it in-band.
|
||||||
@@ -686,11 +733,6 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMessage sends the message with an optional subscription to topic first.
|
|
||||||
sendMessage(msg, uploadCompletionPromise, uploader) {
|
|
||||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendImageAttachment sends the image bits inband as Drafty message.
|
// sendImageAttachment sends the image bits inband as Drafty message.
|
||||||
sendImageAttachment(caption, blob) {
|
sendImageAttachment(caption, blob) {
|
||||||
const mime = this.state.imagePreview.type;
|
const mime = this.state.imagePreview.type;
|
||||||
@@ -727,7 +769,7 @@ class MessagesView extends React.Component {
|
|||||||
});
|
});
|
||||||
if (caption) {
|
if (caption) {
|
||||||
msg = Drafty.appendLineBreak(msg);
|
msg = Drafty.appendLineBreak(msg);
|
||||||
msg = Drafty.append(msg, Drafty.init(caption));
|
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||||
}
|
}
|
||||||
// Pass data and the uploader to the TinodeWeb.
|
// Pass data and the uploader to the TinodeWeb.
|
||||||
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
this.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||||
@@ -753,7 +795,7 @@ class MessagesView extends React.Component {
|
|||||||
});
|
});
|
||||||
if (caption) {
|
if (caption) {
|
||||||
msg = Drafty.appendLineBreak(msg);
|
msg = Drafty.appendLineBreak(msg);
|
||||||
msg = Drafty.append(msg, Drafty.init(caption));
|
msg = Drafty.append(msg, Drafty.parse(caption));
|
||||||
}
|
}
|
||||||
this.sendMessage(msg);
|
this.sendMessage(msg);
|
||||||
});
|
});
|
||||||
@@ -793,6 +835,143 @@ class MessagesView extends React.Component {
|
|||||||
uploader.cancel();
|
uploader.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePickReply(m) {
|
||||||
|
this.setState({reply: null});
|
||||||
|
if (m) {
|
||||||
|
let cont = m.content
|
||||||
|
if (cont) {
|
||||||
|
if (typeof cont == 'string') {
|
||||||
|
cont = Drafty.init(cont);
|
||||||
|
}
|
||||||
|
cont = Drafty.preview(cont, 30, quotePreviewFmt);
|
||||||
|
|
||||||
|
// Get the author.
|
||||||
|
let header;
|
||||||
|
const seq = m.seq;
|
||||||
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
|
const m0 = topic.findMessage(seq);
|
||||||
|
let thisFrom;
|
||||||
|
if (m0) {
|
||||||
|
thisFrom = m0.from || 'chan';
|
||||||
|
if (thisFrom != this.props.myUserId) {
|
||||||
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
|
const user = topic.userDesc(thisFrom);
|
||||||
|
if (user && user.public) {
|
||||||
|
header = user.public.fn;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
header = this.props.myUserName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header = Drafty.init(header || '');
|
||||||
|
// Make small image previews.
|
||||||
|
const ents = [];
|
||||||
|
if (cont.ent) {
|
||||||
|
Drafty.entities(cont, (data, idx, tp) => {
|
||||||
|
if (tp == 'IM') {
|
||||||
|
ents.push({
|
||||||
|
tp: tp,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn all images into thumbnails.
|
||||||
|
const promises = [];
|
||||||
|
ents.map((ex) => {
|
||||||
|
let p = new Promise((resolve, reject) => {
|
||||||
|
const handleFailure = () => {
|
||||||
|
ex.data.val = '';
|
||||||
|
ex.data.name = '';
|
||||||
|
ex.data.width = IMAGE_THUMBNAIL_DIM;
|
||||||
|
ex.data.height = IMAGE_THUMBNAIL_DIM;
|
||||||
|
};
|
||||||
|
const scale = (origBlob) => {
|
||||||
|
imageScaled(origBlob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, false,
|
||||||
|
// Success
|
||||||
|
(mime, blob, width, height, fname) => {
|
||||||
|
ex.data.mime = mime;
|
||||||
|
ex.data.size = blob.size;
|
||||||
|
ex.data.width = width;
|
||||||
|
ex.data.height = height;
|
||||||
|
ex.data.name = fname;
|
||||||
|
ex.data.ref = undefined;
|
||||||
|
|
||||||
|
blobToBase64(blob, (blobMime, tinyBits64) => {
|
||||||
|
ex.data.val = tinyBits64;
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Failure
|
||||||
|
(err) => {
|
||||||
|
handleFailure();
|
||||||
|
reject(`Could not scale image: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ex.data.val) {
|
||||||
|
const b = base64ToBlob(ex.data.val, ex.data.mime);
|
||||||
|
if (b) {
|
||||||
|
scale(b);
|
||||||
|
} else {
|
||||||
|
handleFailure();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const from = this.props.tinode.authorizeURL(sanitizeImageUrl(ex.data.ref));
|
||||||
|
fetch(from)
|
||||||
|
.then(e => {
|
||||||
|
if (e.ok) {
|
||||||
|
return e.blob();
|
||||||
|
} else {
|
||||||
|
handleFailure();
|
||||||
|
reject(`Image fetch unsuccessful: ${e.status} - ${e.statusText}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((b) => scale(b))
|
||||||
|
.catch((err) => reject(`Error fetching image data: ${err}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
promises.push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.catch((err) => {
|
||||||
|
this.props.onError(err, 'err');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// All done. Create a reply quote.
|
||||||
|
const msg = Drafty.createQuote(header, cont, letterTileColorId(thisFrom));
|
||||||
|
this.setState({reply: {content: msg, seq: m.seq}});
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelReply() {
|
||||||
|
this.setState({reply: null})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(replyToSeq) {
|
||||||
|
const ref = this.getOrCreateMessageRef(replyToSeq);
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = ref.current;
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({block: "center", behavior: "smooth"});
|
||||||
|
element.style.backgroundColor = 'rgb(0, 0, 0, 0.4)';
|
||||||
|
setTimeout(() => { element.style.backgroundColor = ''; } , 1000);
|
||||||
|
} else {
|
||||||
|
console.error("Unresolved message ref: seqId", replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
|
|
||||||
@@ -812,6 +991,10 @@ class MessagesView extends React.Component {
|
|||||||
component2 = (
|
component2 = (
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
content={this.state.imagePreview}
|
content={this.state.imagePreview}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
replyTo={this.state.reply}
|
||||||
|
formatter={draftyFormatter}
|
||||||
|
onCancelReply={this.handleCancelReply}
|
||||||
onClose={this.handleClosePreview}
|
onClose={this.handleClosePreview}
|
||||||
onSendMessage={this.sendImageAttachment} />
|
onSendMessage={this.sendImageAttachment} />
|
||||||
);
|
);
|
||||||
@@ -827,6 +1010,10 @@ class MessagesView extends React.Component {
|
|||||||
component2 = (
|
component2 = (
|
||||||
<DocPreview
|
<DocPreview
|
||||||
content={this.state.docPreview}
|
content={this.state.docPreview}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
replyTo={this.state.reply}
|
||||||
|
formatter={draftyFormatter}
|
||||||
|
onCancelReply={this.handleCancelReply}
|
||||||
onClose={this.handleClosePreview}
|
onClose={this.handleClosePreview}
|
||||||
onSendMessage={this.sendFileAttachment} />
|
onSendMessage={this.sendFileAttachment} />
|
||||||
);
|
);
|
||||||
@@ -881,6 +1068,13 @@ class MessagesView extends React.Component {
|
|||||||
chatBoxClass='chat-box';
|
chatBoxClass='chat-box';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ref for this chat message.
|
||||||
|
const ref = this.getOrCreateMessageRef(msg.seq);
|
||||||
|
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
|
||||||
|
if (!replyToSeq || isNaN(replyToSeq)) {
|
||||||
|
replyToSeq = null;
|
||||||
|
}
|
||||||
|
|
||||||
messageNodes.push(
|
messageNodes.push(
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
@@ -896,12 +1090,18 @@ class MessagesView extends React.Component {
|
|||||||
sequence={sequence}
|
sequence={sequence}
|
||||||
received={deliveryStatus}
|
received={deliveryStatus}
|
||||||
uploader={msg._uploader}
|
uploader={msg._uploader}
|
||||||
viewportWidth={this.props.viewportWidth}
|
viewportWidth={this.props.viewportWidth} // Used by `formatter`.
|
||||||
showContextMenu={this.state.channel? false : this.handleShowContextMenuMessage}
|
showContextMenu={this.state.channel? false : this.handleShowContextMenuMessage}
|
||||||
onImagePreview={this.handleImagePostview}
|
onImagePreview={this.handleImagePostview}
|
||||||
onFormResponse={this.handleFormResponse}
|
onFormResponse={this.handleFormResponse}
|
||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
onCancelUpload={this.handleCancelUpload}
|
onCancelUpload={this.handleCancelUpload}
|
||||||
|
pickReply={this.handlePickReply}
|
||||||
|
replyToSeq={replyToSeq}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
formatter={draftyFormatter}
|
||||||
|
ref={ref}
|
||||||
|
userIsWriter={this.state.isWriter}
|
||||||
key={msg.seq} />
|
key={msg.seq} />
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -997,12 +1197,17 @@ class MessagesView extends React.Component {
|
|||||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||||
:
|
:
|
||||||
<SendMessage
|
<SendMessage
|
||||||
|
tinode={this.props.tinode}
|
||||||
disabled={!this.state.isWriter}
|
disabled={!this.state.isWriter}
|
||||||
onSendMessage={this.sendMessage}
|
|
||||||
onKeyPress={this.sendKeyPress}
|
onKeyPress={this.sendKeyPress}
|
||||||
|
onSendMessage={this.sendMessage}
|
||||||
onAttachFile={this.handleAttachFile}
|
onAttachFile={this.handleAttachFile}
|
||||||
onAttachImage={this.handleAttachImage}
|
onAttachImage={this.handleAttachImage}
|
||||||
onError={this.props.onError} />}
|
onError={this.props.onError}
|
||||||
|
replyTo={this.state.reply}
|
||||||
|
onQuoteClick={this.handleQuoteClick}
|
||||||
|
formatter={draftyFormatter}
|
||||||
|
onCancelReply={this.handleCancelReply} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1013,4 +1218,175 @@ class MessagesView extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Transforms styles (and corresponding entities) for formatting reply quotes.
|
||||||
|
function quotePreviewFmt(fmt, ent) {
|
||||||
|
let tp = fmt.tp;
|
||||||
|
if (!tp) {
|
||||||
|
if (!ent || !ent.tp) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
tp = ent.tp;
|
||||||
|
}
|
||||||
|
const new_fmt = {at: fmt.at, len: fmt.len, tp: fmt.tp};
|
||||||
|
switch (tp) {
|
||||||
|
case 'BR':
|
||||||
|
// Replace new line with a space.
|
||||||
|
return [null, null];
|
||||||
|
case 'HL':
|
||||||
|
return [new_fmt, ent];
|
||||||
|
case 'LN':
|
||||||
|
// Disable links in previews.
|
||||||
|
return [null, null];
|
||||||
|
case 'IM':
|
||||||
|
// Keep images as is.
|
||||||
|
return [new_fmt, ent];
|
||||||
|
case 'BN':
|
||||||
|
new_fmt.tp = null;
|
||||||
|
return [new_fmt, { tp: 'IC', data: { orig: 'BN', iconName: 'button'}}];
|
||||||
|
case 'FM':
|
||||||
|
new_fmt.tp = null;
|
||||||
|
return [new_fmt, {tp: 'IC', data: { orig: 'FM', iconName: 'form'}}];
|
||||||
|
case 'RW':
|
||||||
|
return [null, null];
|
||||||
|
case 'EX':
|
||||||
|
// Make it an icon.
|
||||||
|
new_fmt.tp = null;
|
||||||
|
if (new_fmt.at == -1) {
|
||||||
|
// Render it normally.
|
||||||
|
new_fmt.at = 0;
|
||||||
|
}
|
||||||
|
return [new_fmt, {tp: 'IC', data: { orig: 'EX', iconName: 'attachment'}}];
|
||||||
|
case 'QQ':
|
||||||
|
// Quote/citation.
|
||||||
|
return [null,null];
|
||||||
|
default:
|
||||||
|
return [new_fmt, ent];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts Drafty elements into React classes.
|
||||||
|
// 'this' is set by the caller.
|
||||||
|
function draftyFormatter(style, data, values, key) {
|
||||||
|
if (style == 'EX') {
|
||||||
|
// attachments are handled elsewhere.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el = Drafty.tagName(style);
|
||||||
|
if (el) {
|
||||||
|
const { formatMessage } = this.props.intl;
|
||||||
|
let attr = Drafty.attrValue(style, data) || {};
|
||||||
|
attr.key = key;
|
||||||
|
switch (style) {
|
||||||
|
case 'HL':
|
||||||
|
// Highlighted text. Assign class name.
|
||||||
|
attr.className = 'highlight';
|
||||||
|
break;
|
||||||
|
case 'IM':
|
||||||
|
// Additional processing for images
|
||||||
|
if (data) {
|
||||||
|
attr.className = 'inline-image';
|
||||||
|
const dim = fitImageSize(data.width, data.height,
|
||||||
|
this.props.hasOwnProperty('viewportWidth') ? Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) : REM_SIZE * 34.5,
|
||||||
|
REM_SIZE * 24, false) ||
|
||||||
|
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
||||||
|
attr.style = {
|
||||||
|
width: dim.dstWidth + 'px',
|
||||||
|
height: dim.dstHeight + 'px',
|
||||||
|
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||||||
|
minWidth: dim.dstWidth + 'px',
|
||||||
|
minHeight: dim.dstHeight + 'px'
|
||||||
|
};
|
||||||
|
if (!Drafty.isProcessing(data)) {
|
||||||
|
attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src));
|
||||||
|
attr.alt = data.name;
|
||||||
|
if (attr.src) {
|
||||||
|
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||||||
|
// Allow previews for large enough images.
|
||||||
|
attr.onClick = this.handleImagePreview;
|
||||||
|
attr.className += ' image-clickable';
|
||||||
|
}
|
||||||
|
attr.loading = 'lazy';
|
||||||
|
} else {
|
||||||
|
attr.src = 'img/broken_image.png';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use custom element instead of <img>.
|
||||||
|
el = UploadingImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'BN':
|
||||||
|
// Button
|
||||||
|
attr.onClick = this.handleFormButtonClick;
|
||||||
|
let inner = React.Children.map(values, (child) => {
|
||||||
|
return typeof child == 'string' ? child : undefined;
|
||||||
|
});
|
||||||
|
if (!inner || inner.length == 0) {
|
||||||
|
inner = [attr.name]
|
||||||
|
}
|
||||||
|
// Get text which will be sent back when the button is clicked.
|
||||||
|
attr['data-title'] = inner.join('');
|
||||||
|
break;
|
||||||
|
case 'MN':
|
||||||
|
// Mention
|
||||||
|
if (data && data.hasOwnProperty('colorId')) {
|
||||||
|
attr.className = 'mn-dark-color' + data.colorId;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'FM':
|
||||||
|
// Form
|
||||||
|
attr.className = 'bot-form';
|
||||||
|
break;
|
||||||
|
case 'RW':
|
||||||
|
// Form element formatting is dependent on element content.
|
||||||
|
break;
|
||||||
|
case 'QQ':
|
||||||
|
// Quote/citation.
|
||||||
|
attr.className = 'reply-quote'
|
||||||
|
attr.onClick = this.handleQuoteClick;
|
||||||
|
break;
|
||||||
|
case 'IC':
|
||||||
|
// Icon.
|
||||||
|
if (data.iconName == 'button') {
|
||||||
|
attr.className = 'flat-button faux';
|
||||||
|
} else {
|
||||||
|
let iconName;
|
||||||
|
let iconTitle;
|
||||||
|
switch (data.iconName) {
|
||||||
|
case 'form':
|
||||||
|
iconName = 'dashboard';
|
||||||
|
iconTitle = 'drafty_form';
|
||||||
|
break;
|
||||||
|
case 'attachment':
|
||||||
|
iconName = 'attachment';
|
||||||
|
iconTitle = 'drafty_attachment';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
el = React.Fragment;
|
||||||
|
if (iconName && iconTitle) {
|
||||||
|
const iconKey = data.orig.toLowerCase();
|
||||||
|
values = [<i key={iconKey} className="material-icons">{iconName}</i>,
|
||||||
|
formatMessage(messages[iconTitle])].concat(' ', values || []);
|
||||||
|
} else {
|
||||||
|
values = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (el == '_UNKN') {
|
||||||
|
// Unknown element.
|
||||||
|
el = React.Fragment;
|
||||||
|
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return React.createElement(el, attr, values);
|
||||||
|
} else {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default injectIntl(MessagesView);
|
export default injectIntl(MessagesView);
|
||||||
|
|||||||
@@ -901,16 +901,21 @@ class TinodeWeb extends React.Component {
|
|||||||
|
|
||||||
// User is sending a message, either plain text or a drafty object, possibly
|
// User is sending a message, either plain text or a drafty object, possibly
|
||||||
// with attachments.
|
// with attachments.
|
||||||
// - msg: Drafty message with content
|
// - msg - Drafty message with content
|
||||||
// - promise: if message is dependent on uploading media, a Promise to be resolved when the upload is completed.
|
// - promise - Promise to be resolved when the upload is completed
|
||||||
// - uploader: for tracking progress
|
// - uploader - for tracking progress
|
||||||
handleSendMessage(msg, promise, uploader) {
|
// - head - head dictionary to be attached to the message
|
||||||
|
handleSendMessage(msg, promise, uploader, head) {
|
||||||
const topic = this.tinode.getTopic(this.state.topicSelected);
|
const topic = this.tinode.getTopic(this.state.topicSelected);
|
||||||
|
|
||||||
msg = topic.createMessage(msg, false);
|
msg = topic.createMessage(msg, false);
|
||||||
// The uploader is used to show progress.
|
// The uploader is used to show progress.
|
||||||
msg._uploader = uploader;
|
msg._uploader = uploader;
|
||||||
|
|
||||||
|
if (head) {
|
||||||
|
msg.head = Object.assign(msg.head || {}, head);
|
||||||
|
}
|
||||||
|
|
||||||
if (!topic.isSubscribed()) {
|
if (!topic.isSubscribed()) {
|
||||||
// Topic is not subscribed yet. Subscribe.
|
// Topic is not subscribed yet. Subscribe.
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
@@ -1675,6 +1680,8 @@ class TinodeWeb extends React.Component {
|
|||||||
(this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)}
|
(this.state.mobilePanel !== 'topic-view' || this.state.infoPanel)}
|
||||||
topic={this.state.topicSelected}
|
topic={this.state.topicSelected}
|
||||||
myUserId={this.state.myUserId}
|
myUserId={this.state.myUserId}
|
||||||
|
// User public.fn.
|
||||||
|
myUserName={this.state.sidePanelTitle}
|
||||||
serverVersion={this.state.serverVersion}
|
serverVersion={this.state.serverVersion}
|
||||||
serverAddress={this.state.serverAddress}
|
serverAddress={this.state.serverAddress}
|
||||||
applicationVisible={this.state.applicationVisible}
|
applicationVisible={this.state.applicationVisible}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
// Single message, sent or received.
|
// Single message, sent or received.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
import Attachment from './attachment.jsx';
|
import Attachment from './attachment.jsx';
|
||||||
import LetterTile from './letter-tile.jsx';
|
import LetterTile from './letter-tile.jsx';
|
||||||
import ReceivedMarker from './received-marker.jsx'
|
import ReceivedMarker from './received-marker.jsx'
|
||||||
import UploadingImage from './uploading-image.jsx'
|
|
||||||
|
|
||||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
||||||
|
|
||||||
export default class ChatMessage extends React.Component {
|
class BaseChatMessage extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
@@ -26,6 +25,8 @@ export default class ChatMessage extends React.Component {
|
|||||||
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);
|
||||||
|
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImagePreview(e) {
|
handleImagePreview(e) {
|
||||||
@@ -61,8 +62,13 @@ export default class ChatMessage extends React.Component {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
||||||
|
if (this.props.userIsWriter &&
|
||||||
|
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
|
||||||
|
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
|
||||||
|
menuItems.push('menu_item_reply');
|
||||||
|
}
|
||||||
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
||||||
y: e.pageY, x: e.pageX }, menuItems);
|
y: e.pageY, x: e.pageX, pickReply: this.props.pickReply }, menuItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProgress(ratio) {
|
handleProgress(ratio) {
|
||||||
@@ -73,6 +79,15 @@ export default class ChatMessage extends React.Component {
|
|||||||
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const replyToSeq = this.props.replyToSeq;
|
||||||
|
if (replyToSeq) {
|
||||||
|
this.props.onQuoteClick(replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const sideClass = this.props.deleted ? 'center' :
|
const sideClass = this.props.deleted ? 'center' :
|
||||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||||
@@ -102,7 +117,8 @@ export default class ChatMessage extends React.Component {
|
|||||||
onError={this.props.onError}
|
onError={this.props.onError}
|
||||||
key={i} />);
|
key={i} />);
|
||||||
}, this);
|
}, this);
|
||||||
content = React.createElement(React.Fragment, null, Drafty.format(content, draftyFormatter, this));
|
const tree = Drafty.format(content, this.props.formatter, this);
|
||||||
|
content = React.createElement(React.Fragment, null, tree);
|
||||||
} else if (this.props.deleted) {
|
} else if (this.props.deleted) {
|
||||||
// Message represents a range of deleted messages.
|
// Message represents a range of deleted messages.
|
||||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||||
@@ -116,7 +132,7 @@ export default class ChatMessage extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={sideClass}>
|
<li ref={this.props.innerRef} className={sideClass}>
|
||||||
{this.props.userFrom && this.props.response ?
|
{this.props.userFrom && this.props.response ?
|
||||||
<div className="avatar-box">
|
<div className="avatar-box">
|
||||||
{fullDisplay ?
|
{fullDisplay ?
|
||||||
@@ -165,86 +181,7 @@ export default class ChatMessage extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert Drafty object to a tree of React elements.
|
const IntlChatMessage = injectIntl(BaseChatMessage);
|
||||||
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
const ChatMessage = React.forwardRef((props, ref) => <IntlChatMessage innerRef = {ref} {...props} />);
|
||||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
// Converts Drafty elements into React classes.
|
export default ChatMessage;
|
||||||
// 'this' is set by the caller.
|
|
||||||
function draftyFormatter(style, data, values, key) {
|
|
||||||
if (style == 'EX') {
|
|
||||||
// attachments are handled elsewhere.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let el = Drafty.tagName(style);
|
|
||||||
if (el) {
|
|
||||||
const attr = Drafty.attrValue(style, data) || {};
|
|
||||||
attr.key = key;
|
|
||||||
switch (style) {
|
|
||||||
case 'HL':
|
|
||||||
// Highlighted text. Assign class name.
|
|
||||||
attr.className = 'highlight';
|
|
||||||
break;
|
|
||||||
case 'IM':
|
|
||||||
// Additional processing for images
|
|
||||||
if (data) {
|
|
||||||
attr.className = 'inline-image';
|
|
||||||
const dim = fitImageSize(data.width, data.height,
|
|
||||||
Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5), REM_SIZE * 24, false) ||
|
|
||||||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
|
||||||
attr.style = {
|
|
||||||
width: dim.dstWidth + 'px',
|
|
||||||
height: dim.dstHeight + 'px',
|
|
||||||
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
|
||||||
minWidth: dim.dstWidth + 'px',
|
|
||||||
minHeight: dim.dstHeight + 'px'
|
|
||||||
};
|
|
||||||
if (!Drafty.isProcessing(data)) {
|
|
||||||
attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src));
|
|
||||||
attr.alt = data.name;
|
|
||||||
if (attr.src) {
|
|
||||||
attr.onClick = this.handleImagePreview;
|
|
||||||
attr.className += ' image-clickable';
|
|
||||||
attr.loading = 'lazy';
|
|
||||||
} else {
|
|
||||||
attr.src = 'img/broken_image.png';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use custom element instead of <img>.
|
|
||||||
el = UploadingImage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'BN':
|
|
||||||
// Button
|
|
||||||
attr.onClick = this.handleFormButtonClick;
|
|
||||||
let inner = React.Children.map(values, (child) => {
|
|
||||||
return typeof child == 'string' ? child : undefined;
|
|
||||||
});
|
|
||||||
if (!inner || inner.length == 0) {
|
|
||||||
inner = [attr.name]
|
|
||||||
}
|
|
||||||
// Get text which will be sent back when the button is clicked.
|
|
||||||
attr['data-title'] = inner.join('');
|
|
||||||
break;
|
|
||||||
case 'FM':
|
|
||||||
// Form
|
|
||||||
attr.className = 'bot-form';
|
|
||||||
break;
|
|
||||||
case 'RW':
|
|
||||||
// Form element formatting is dependent on element content.
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (el == '_UNKN') {
|
|
||||||
// Unknown element.
|
|
||||||
el = React.Fragment;
|
|
||||||
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return React.createElement(el, attr, values);
|
|
||||||
} else {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Unmute',
|
defaultMessage: 'Unmute',
|
||||||
description: 'Turn notifications on'
|
description: 'Turn notifications on'
|
||||||
},
|
},
|
||||||
|
reply: {
|
||||||
|
id: 'menu_item_reply',
|
||||||
|
defaultMessage: 'Reply',
|
||||||
|
description: 'Reply to message'
|
||||||
|
},
|
||||||
topic_delete: {
|
topic_delete: {
|
||||||
id: 'menu_item_delete_topic',
|
id: 'menu_item_delete_topic',
|
||||||
defaultMessage: 'Delete',
|
defaultMessage: 'Delete',
|
||||||
@@ -171,6 +176,13 @@ class ContextMenu extends React.Component {
|
|||||||
return this.retryMessage(params, errorHandler);
|
return this.retryMessage(params, errorHandler);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'menu_item_reply': {
|
||||||
|
id: 'menu_item_reply',
|
||||||
|
title: formatMessage(messages.reply),
|
||||||
|
handler: (params, errorHandler) => {
|
||||||
|
return this.replyToMessage(params, errorHandler);
|
||||||
|
}
|
||||||
|
},
|
||||||
'topic_unmute': {
|
'topic_unmute': {
|
||||||
id: 'topic_unmute',
|
id: 'topic_unmute',
|
||||||
title: formatMessage(messages.unmute),
|
title: formatMessage(messages.unmute),
|
||||||
@@ -342,7 +354,7 @@ class ContextMenu extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
|
console.error("Invalid menu item ID", e.currentTarget.dataset.id);
|
||||||
} else {
|
} else {
|
||||||
this.props.onAction(
|
this.props.onAction(
|
||||||
item.id,
|
item.id,
|
||||||
@@ -411,9 +423,13 @@ class ContextMenu extends React.Component {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyToMessage(params, errorHandler) {
|
||||||
|
params.pickReply({ seq: params.seq, content: params.content });
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const menu = [];
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let menu = [];
|
|
||||||
this.props.items.map((item) => {
|
this.props.items.map((item) => {
|
||||||
if (typeof item == 'string') {
|
if (typeof item == 'string') {
|
||||||
item = this.MenuItems[item];
|
item = this.MenuItems[item];
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export default class DocPreview extends React.PureComponent {
|
|||||||
</div>
|
</div>
|
||||||
<SendMessage
|
<SendMessage
|
||||||
noInput={true}
|
noInput={true}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
replyTo={this.props.replyTo}
|
||||||
|
formatter={this.props.formatter}
|
||||||
|
onCancelReply={this.props.onCancelReply}
|
||||||
onSendMessage={this.handleSendDoc}
|
onSendMessage={this.handleSendDoc}
|
||||||
onError={this.props.onError} />
|
onError={this.props.onError} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export default class ImagePreview extends React.PureComponent {
|
|||||||
<SendMessage
|
<SendMessage
|
||||||
messagePrompt="add_image_caption"
|
messagePrompt="add_image_caption"
|
||||||
acceptBlank={true}
|
acceptBlank={true}
|
||||||
|
tinode={this.props.tinode}
|
||||||
|
replyTo={this.props.replyTo}
|
||||||
|
formatter={this.props.formatter}
|
||||||
|
onCancelReply={this.props.onCancelReply}
|
||||||
onSendMessage={this.handleSendImage}
|
onSendMessage={this.handleSendImage}
|
||||||
onError={this.props.onError} />
|
onError={this.props.onError} />
|
||||||
:
|
:
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.handleSend = this.handleSend.bind(this);
|
this.handleSend = this.handleSend.bind(this);
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
||||||
|
|
||||||
|
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -141,6 +143,15 @@ class SendMessage extends React.PureComponent {
|
|||||||
this.setState(newState);
|
this.setState(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.props.replyTo && this.props.onQuoteClick) {
|
||||||
|
const replyToSeq = this.props.replyTo.seq;
|
||||||
|
this.props.onQuoteClick(replyToSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const prompt = this.props.disabled ?
|
const prompt = this.props.disabled ?
|
||||||
@@ -148,7 +159,22 @@ class SendMessage extends React.PureComponent {
|
|||||||
(this.props.messagePrompt ?
|
(this.props.messagePrompt ?
|
||||||
formatMessage(messages[this.props.messagePrompt]) :
|
formatMessage(messages[this.props.messagePrompt]) :
|
||||||
formatMessage(messages.type_new_message));
|
formatMessage(messages.type_new_message));
|
||||||
|
|
||||||
|
let quote = null;
|
||||||
|
if (this.props.replyTo) {
|
||||||
|
quote = Drafty.format(this.props.replyTo.content, this.props.formatter, this);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div id="send-message-wrapper">
|
||||||
|
{quote ?
|
||||||
|
<div className="reply-quote-preview">
|
||||||
|
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancelReply();}}><i className="material-icons gray">close</i></a>
|
||||||
|
{quote}
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null}
|
||||||
|
|
||||||
<div id="send-message-panel">
|
<div id="send-message-panel">
|
||||||
{!this.props.disabled ?
|
{!this.props.disabled ?
|
||||||
<>
|
<>
|
||||||
@@ -182,6 +208,7 @@ class SendMessage extends React.PureComponent {
|
|||||||
<div id="writing-disabled">{prompt}</div>
|
<div id="writing-disabled">{prompt}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+618
-148
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
+308
-40
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