Compare commits

...

3 Commits

Author SHA1 Message Date
robertpin
9aa870b627 Added drag and drop functionality (#2) 2021-08-13 15:10:01 +03:00
robertpin
2153305ae7 Merge branch 'master' into rpintilii/chat-file-upload 2021-08-13 11:49:54 +03:00
Horatiu Muresan
6732709ef1 Implement chat file share (#1)
* Implement chat file share

* address review
2021-08-13 11:45:29 +03:00
13 changed files with 365 additions and 10 deletions

View File

@@ -166,6 +166,10 @@
* {
background-color: transparent;
}
&.drop-target {
background-color: $dominantSpeakerBg;
}
}
.send-button-container {
@@ -574,3 +578,46 @@
background: #36383C;
border-radius: 3px;
}
.file-share {
&-icon {
width: 25px;
height: 25px;
&.white {
fill: #FFF;
}
}
&-hidden-file-input {
display: none;
}
&-message-container {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
&-thumbnail {
width: 100px;
height: 100px;
border-radius: 3px;
}
&-download-pane {
cursor: 'pointer';
display: flex;
align-items: center;
justify-content: space-between;
}
&-icon-download {
fill: #FFF;
width: 18px;
height: 18px;
margin-right: 5px;
}
}

View File

@@ -74,7 +74,10 @@
"message": "Message",
"messageAccessibleTitle": "{{user}} says:",
"messageAccessibleTitleMe": "me says:",
"smileysPanel": "Emoji panel"
"smileysPanel": "Emoji panel",
"fileTransfer": {
"fileTooBig": "Maximum allowed size: {{size}} MB"
}
},
"chromeExtensionBanner": {
"installExtensionText": "Install the extension for Google Calendar and Office 365 integration",

View File

@@ -125,6 +125,7 @@ export default [
'failICE',
'feedbackPercentage',
'fileRecordingsEnabled',
'fileTransferCdnServer',
'firefox_fake_device',
'forceJVB121Ratio',
'forceTurnRelay',

View File

@@ -26,6 +26,7 @@ declare var interfaceConfig: Object;
* @type {Object}
*/
const INITIAL_NON_RN_STATE = {
fileTransferCdnServer: 'https://jitsi-file-server.herokuapp.com'
};
/**

View File

@@ -132,3 +132,4 @@ export { default as IconWifi2Bars } from './wifi-2.svg';
export { default as IconWifi3Bars } from './wifi-3.svg';
export { default as IconYahoo } from './yahoo.svg';
export { default as IconSip } from './sip.svg';
export { default as IconPdf } from './pdf.svg';

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="m451.7 99.72-71.4-71.44c-15.6-15.55-46.3-28.28-68.3-28.28h-240c-22 0-40 18-40 40v432c0 22 18 40 40 40h368c22 0 40-18 40-40v-304c0-22-12.7-52.7-28.3-68.28z"/><path fill="#fff" d="m448 472c0 4.3-3.7 8-8 8h-368c-4.34 0-8-3.7-8-8v-432c0-4.34 3.66-8 8-8h240c2.4 0 5.1.30 8 .85v127.2h127.1c.6 2.9.9 5.6.9 8v304z"/><path d="m414.5 316.8c-2.1 1.3-8.1 2.1-11.9 2.1-12.4 0-27.6-5.7-49.1-14.9 8.3-.6 15.8-.9 22.6-.9 12.4 0 16 0 28.2 3.1 12.1 3 12.2 9.3 10.2 10.6zm-215.1 1.9c4.8-8.4 9.7-17.3 14.7-26.8 12.2-23.1 20-41.3 25.7-56.2 11.5 20.9 25.8 38.6 42.5 52.8 2.1 1.8 4.3 3.5 6.7 5.3-34.1 6.8-63.6 15-89.6 24.9zm39.8-218.9c6.8 0 10.7 17.06 11 33.16.3 16-3.4 27.2-8.1 35.6-3.9-12.4-5.7-31.8-5.7-44.5 0 0-.3-24.26 2.8-24.26zm-133.4 307.2c3.9-10.5 19.1-31.3 41.6-49.8 1.4-1.1 4.9-4.4 8.1-7.4-23.5 37.6-39.3 52.5-49.7 57.2zm315.2-112.3c-6.8-6.7-22-10.2-45-10.5-15.6-.2-34.3 1.2-54.1 3.9-8.8-5.1-17.9-10.6-25.1-17.3-19.2-18-35.2-42.9-45.2-70.3.6-2.6 1.2-4.8 1.7-7.1 0 0 10.8-61.5 7.9-82.3-.4-2.9-.6-3.7-1.4-5.9l-.9-2.5c-2.9-6.76-8.7-13.96-17.8-13.57l-5.3-.17h-.1c-10.1 0-18.4 5.17-20.5 12.84-6.6 24.3.2 60.5 12.5 107.4l-3.2 7.7c-8.8 21.4-19.8 43-29.5 62l-1.3 2.5c-10.2 20-19.5 37-27.9 51.4l-8.7 4.6c-.6.4-15.5 8.2-19 10.3-29.6 17.7-49.28 37.8-52.54 53.8-1.04 5-.26 11.5 5.01 14.6l8.4 4.2c3.63 1.8 7.53 2.7 11.43 2.7 21.1 0 45.6-26.2 79.3-85.1 39-12.7 83.4-23.3 122.3-29.1 29.6 16.7 66 28.3 89 28.3 4.1 0 7.6-.4 10.5-1.2 4.4-1.1 8.1-3.6 10.4-7.1 4.4-6.7 5.4-15.9 4.1-25.4-.3-2.8-2.6-6.3-5-8.7z"/><path fill="#fff" d="m429.1 122.3c1.6 1.6 3.1 3.5 4.6 5.7h-81.7v-81.73c2.2 1.52 4.1 3.08 5.7 4.64l71.4 71.39z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -3,6 +3,7 @@
import { Component } from 'react';
import type { Dispatch } from 'redux';
import getRoomName from '../../base/config/getRoomName';
import { getLocalParticipant } from '../../base/participants';
import { sendMessage } from '../actions';
import { SMALL_WIDTH_THRESHOLD } from '../constants';
@@ -97,7 +98,9 @@ export default class AbstractChat<P: Props> extends Component<P> {
* @returns {{
* _isOpen: boolean,
* _messages: Array<Object>,
* _showNamePrompt: boolean
* _showNamePrompt: boolean,
* _fileTransferCdnServer: string,
* _roomName: string
* }}
*/
export function _mapStateToProps(state: Object) {
@@ -108,6 +111,8 @@ export function _mapStateToProps(state: Object) {
_isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
_isOpen: isOpen,
_messages: messages,
_showNamePrompt: !_localParticipant?.name
_showNamePrompt: !_localParticipant?.name,
_fileTransferCdnServer: state['features/base/config'].fileTransferCdnServer,
_roomName: getRoomName(state)
};
}

View File

@@ -15,6 +15,11 @@ const TIMESTAMP_FORMAT = 'H:mm';
*/
export type Props = {
/**
* The server which hosts the file transfered from chat.
*/
_fileTransferCdnServer: string,
/**
* The representation of a chat message.
*/

View File

@@ -137,8 +137,10 @@ class Chat extends AbstractChat<Props> {
</TouchmoveHack>
<MessageRecipient />
<ChatInput
fileTransferCdnServer = { this.props._fileTransferCdnServer }
onResize = { this._onChatInputResize }
onSend = { this._onSendMessage } />
onSend = { this._onSendMessage }
roomName = { this.props._roomName } />
<KeyboardAvoider />
</>
);

View File

@@ -1,14 +1,17 @@
// @flow
import Spinner from '@atlaskit/spinner';
import React, { Component } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import type { Dispatch } from 'redux';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n';
import { Icon, IconPlane, IconSmile } from '../../../base/icons';
import { Icon, IconPlane, IconSmile, IconDeviceDocument } from '../../../base/icons';
import { connect } from '../../../base/redux';
import { showErrorNotification } from '../../../notifications';
import { MAX_FILE_SHARE_SIZE } from './../../constants';
import SmileysPanel from './SmileysPanel';
/**
@@ -21,6 +24,11 @@ type Props = {
*/
dispatch: Dispatch<any>,
/**
* The server which hosts the file transfered from chat.
*/
fileTransferCdnServer: string,
/**
* Optional callback to invoke when the chat textarea has auto-resized to
* fit overflowing text.
@@ -32,6 +40,11 @@ type Props = {
*/
onSend: Function,
/**
* The backend safe roomname.
*/
roomName: string,
/**
* Invoked to obtain translated strings.
*/
@@ -64,7 +77,8 @@ class ChatInput extends Component<Props, State> {
state = {
message: '',
showSmileysPanel: false
showSmileysPanel: false,
isUploading: false
};
/**
@@ -77,6 +91,8 @@ class ChatInput extends Component<Props, State> {
super(props);
this._textArea = null;
this._fileInputRef = React.createRef();
this._chatInputRef = React.createRef();
// Bind event handlers so they are only bound once for every instance.
this._onDetectSubmit = this._onDetectSubmit.bind(this);
@@ -88,6 +104,11 @@ class ChatInput extends Component<Props, State> {
this._onToggleSmileysPanelKeyPress = this._onToggleSmileysPanelKeyPress.bind(this);
this._onSubmitMessageKeyPress = this._onSubmitMessageKeyPress.bind(this);
this._setTextAreaRef = this._setTextAreaRef.bind(this);
this._onFileInputChange = this._onFileInputChange.bind(this);
this._uploadFile = this._uploadFile.bind(this);
this._onDragOver = this._onDragOver.bind(this);
this._onDragLeave = this._onDragLeave.bind(this);
this._onDrop = this._onDrop.bind(this);
}
/**
@@ -114,7 +135,12 @@ class ChatInput extends Component<Props, State> {
return (
<div className = { `chat-input-container${this.state.message.trim().length ? ' populated' : ''}` }>
<div id = 'chat-input' >
<div
id = 'chat-input'
onDragLeave = { this._onDragLeave }
onDragOver = { this._onDragOver }
onDrop = { this._onDrop }
ref = { this._chatInputRef }>
<div className = 'smiley-input'>
<div id = 'smileysarea'>
<div id = 'smileys'>
@@ -161,6 +187,19 @@ class ChatInput extends Component<Props, State> {
tabIndex = { this.state.message.trim() ? 0 : -1 } >
<Icon src = { IconPlane } />
</div>
{ this.state.isUploading ? <Spinner /> : (
<div>
<label htmlFor = 'file-input'>
<IconDeviceDocument className = 'file-share-icon white' />
</label>
<input
className = 'file-share-hidden-file-input'
id = 'file-input'
onChange = { this._onFileInputChange }
ref = { this._fileInputRef }
type = 'file' />
</div>
) }
</div>
</div>
</div>
@@ -177,6 +216,119 @@ class ChatInput extends Component<Props, State> {
this._textArea && this._textArea.focus();
}
_onDragOver: (e: MouseEvent) => void;
/**
* Drag over handler.
*
* @param {MouseEvent} e - Mouse event.
* @returns {void}
*/
_onDragOver(e) {
e.preventDefault();
this._chatInputRef.current.classList.add('drop-target');
}
_onDragLeave: (e: MouseEvent) => void;
/**
* Drag over handler.
*
* @param {MouseEvent} e - Mouse event.
* @returns {void}
*/
_onDragLeave(e) {
e.preventDefault();
this._chatInputRef.current.classList.remove('drop-target');
}
_onDrop: (e: MouseEvent) => void;
/**
* Drop file handler. Initiates upload of dropped file.
*
* @param {MouseEvent} e - Mouse event.
* @returns {void}
*/
_onDrop(e) {
e.preventDefault();
this._chatInputRef.current.classList.remove('drop-target');
if (e.dataTransfer.files.length > 0) {
this._uploadFile(e.dataTransfer.files[0]);
}
}
_uploadFile: (file: File) => void;
/**
* Uploads file to server.
*
* @param {File} file - File to upload.
* @returns {void}
*/
_uploadFile(file: File) {
if (!file) {
return;
}
const { fileTransferCdnServer, dispatch } = this.props;
const maxAllowedSize = MAX_FILE_SHARE_SIZE * 1024 * 1024;
if (file.size > maxAllowedSize) {
dispatch(showErrorNotification({
titleArguments: {
size: MAX_FILE_SHARE_SIZE
},
titleKey: 'chat.fileTransfer.fileTooBig'
}));
return;
}
const formData = new FormData();
formData.append('file', file, file.name);
formData.append('fqn', this.props.roomName);
this.setState({ isUploading: true });
fetch(`${fileTransferCdnServer}/api/fileUpload`, {
body: formData,
method: 'POST'
})
.then(resp => {
if (resp.ok) {
return resp.json();
}
throw new Error('Invalid response');
})
.then(data => {
const mimeType = encodeURIComponent(data.mimetype);
const fileName = encodeURIComponent(data.fileName);
this.props.onSend(`${fileTransferCdnServer}/${data.url}?mimeType=${mimeType}&fileName=${fileName}`);
})
.catch(err => {
console.error(err);
})
.finally(() => this.setState({ isUploading: false }));
}
_onFileInputChange: () => void;
/**
* Triggers upload of selected file.
*
* @returns {void}
*
*/
_onFileInputChange() {
const file = this._fileInputRef?.current?.files[0];
this._uploadFile(file);
}
_onSubmitMessage: () => void;

View File

@@ -3,15 +3,17 @@
import React from 'react';
import { toArray } from 'react-emoji-render';
import { translate } from '../../../base/i18n';
import { Linkify } from '../../../base/react';
import { connect } from '../../../base/redux';
import { MESSAGE_TYPE_LOCAL } from '../../constants';
import AbstractChatMessage, {
type Props
} from '../AbstractChatMessage';
import PrivateMessageButton from '../PrivateMessageButton';
import ChatMessageFileShare from './ChatMessageFileShare';
/**
* Renders a single chat message.
*/
@@ -31,7 +33,11 @@ class ChatMessage extends AbstractChatMessage<Props> {
content.forEach(i => {
if (typeof i === 'string') {
processedMessage.push(<Linkify key = { i }>{ i }</Linkify>);
if (i.startsWith(this.props._fileTransferCdnServer)) {
processedMessage.push(<ChatMessageFileShare url = { i } />);
} else {
processedMessage.push(<Linkify key = { i }>{ i }</Linkify>);
}
} else {
processedMessage.push(i);
}
@@ -120,4 +126,19 @@ class ChatMessage extends AbstractChatMessage<Props> {
}
}
export default translate(ChatMessage);
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code WaitForOwnerDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Props}
*/
function mapStateToProps(state) {
return {
_fileTransferCdnServer: state['features/base/config'].fileTransferCdnServer
};
}
export default translate(connect(mapStateToProps)(ChatMessage));

View File

@@ -0,0 +1,111 @@
// @flow
import Spinner from '@atlaskit/spinner';
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { IconDownload, IconDeviceDocument, IconPdf } from '../../../base/icons';
type Props = {
url: string
}
const parseFileShareUrl = (url: string) => {
const searchParams = new URLSearchParams(url.substring(url.indexOf('?')));
return {
mimeType: searchParams.get('mimeType'),
fileName: searchParams.get('fileName')
};
};
const parsedMimeTypes = mimeType => {
if (mimeType.startsWith('image')) {
return 'image/*';
}
return mimeType;
};
const getThumbnail = url => fetch(url);
const ChatMessageFileShare = ({ url }: Props) => {
const {
mimeType,
fileName
} = useMemo(() => parseFileShareUrl(url), [ url ]);
const [ isDownloading, setIsDownloading ] = useState(false);
const [ thumbnailUrl, setThumbnailUrl ] = useState(null);
useEffect(() => {
if (!mimeType.startsWith('image/')) {
return;
}
const fetchThumbnail = async () => {
let counter = 0;
const thumbnail = url.replace('files', 'thumbnails');
const intervalHandle = setInterval(async () => {
counter++;
const resp = await getThumbnail(thumbnail);
if (resp.ok && resp.headers.get('content-length') > 0) {
setThumbnailUrl(thumbnail);
clearInterval(intervalHandle);
return;
}
if (counter > 3) {
clearInterval(intervalHandle);
}
}, 3000);
};
fetchThumbnail();
}, [ url, mimeType ]);
const download = useCallback(() => {
setIsDownloading(true);
fetch(url)
.then(res => res.blob())
.then(blob => {
setIsDownloading(false);
const base64Url = window.URL.createObjectURL(blob);
// fake a anchor tag
const a = document.createElement('a');
a.href = base64Url;
a.download = fileName;
a.click();
});
}, []);
return isDownloading ? <Spinner /> : (
<div
className = 'file-share-message-container'>
{
{
'application/pdf': <IconPdf
className = 'file-share-icon' />,
'image/*': <img
className = 'file-share-thumbnail'
src = { thumbnailUrl } />
}[parsedMimeTypes(mimeType)] || <IconDeviceDocument
className = 'file-share-icon white' />
}
<div
className = 'file-share-download-pane'
onClick = { download }>
<IconDownload
className = 'file-share-icon-download' />
{ fileName }
</div>
</div>
);
};
export default ChatMessageFileShare;

View File

@@ -31,3 +31,8 @@ export const MESSAGE_TYPE_LOCAL = 'local';
export const MESSAGE_TYPE_REMOTE = 'remote';
export const SMALL_WIDTH_THRESHOLD = 580;
/**
* The maximum shared file size in MB.
*/
export const MAX_FILE_SHARE_SIZE = 50;