feat(Chat) Improve responsive behaviour further.
* Add buttons to send messages/set nickname. * Redesign message/nickname inputs. * Pin messages to the input. * Add keyboard avoider for Safari. * Make chat content scrollable on mobile.
This commit is contained in:
parent
4c39d83ff1
commit
43761fc398
|
@ -1,3 +1,21 @@
|
|||
/**
|
||||
* Mixins that mimic the way Atlaskit fills the screen with modals at low screen widths.
|
||||
*/
|
||||
@mixin full-size-modal-positioner() {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@mixin full-size-modal-dialog() {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the @atlaskit/flag container up a little bit so it does not cover the
|
||||
* toolbar with the first notification.
|
||||
|
@ -56,4 +74,43 @@
|
|||
.toolbox-button-wth-dialog > div:nth-child(2) {
|
||||
max-height: calc(100vh - #{$newToolbarSizeWithPadding} - 46px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The following selectors keep the chat modal full-size anywhere between 100px
|
||||
* and 580px for desktop or 680px for mobile.
|
||||
*/
|
||||
@media (min-width: 100px) and (max-width: 320px) {
|
||||
.smiley-input {
|
||||
display: none;
|
||||
}
|
||||
.shift-right .focus-lock > div > div {
|
||||
@include full-size-modal-positioner();
|
||||
}
|
||||
|
||||
.shift-right .focus-lock [role="dialog"] {
|
||||
@include full-size-modal-dialog();
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 480px) and (max-width: 580px) {
|
||||
.shift-right .focus-lock > div > div {
|
||||
@include full-size-modal-positioner();
|
||||
}
|
||||
|
||||
.shift-right .focus-lock [role="dialog"] {
|
||||
@include full-size-modal-dialog();
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 580px) and (max-width: 680px) {
|
||||
.mobile-browser {
|
||||
&.shift-right .focus-lock > div > div {
|
||||
@include full-size-modal-positioner();
|
||||
}
|
||||
|
||||
&.shift-right .focus-lock [role="dialog"] {
|
||||
@include full-size-modal-dialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
109
css/_chat.scss
109
css/_chat.scss
|
@ -32,6 +32,13 @@
|
|||
width: $sidebarWidth;
|
||||
word-wrap: break-word;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > :first-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
}
|
||||
|
@ -122,16 +129,61 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 0 16px 24px;
|
||||
|
||||
&.populated {
|
||||
#chat-input {
|
||||
border: 1px solid #619CF4;
|
||||
|
||||
.send-button {
|
||||
background: #1B67EC;
|
||||
cursor: pointer;
|
||||
|
||||
path {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#chat-input {
|
||||
border-top: 1px solid $chatInputSeparatorColor;
|
||||
border: 1px solid $chatInputSeparatorColor;
|
||||
display: flex;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
|
||||
* {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.send-button-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 3px;
|
||||
|
||||
path {
|
||||
fill: $chatInputSeparatorColor;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-browser {
|
||||
.send-button {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.remoteuser {
|
||||
color: #B8C7E0;
|
||||
}
|
||||
|
@ -161,10 +213,47 @@
|
|||
#nickname {
|
||||
text-align: center;
|
||||
color: #9d9d9d;
|
||||
font-size: 18px;
|
||||
margin-top: 30px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
font-size: 16px;
|
||||
margin: auto 0;
|
||||
padding: 0 16px;
|
||||
|
||||
input {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
label {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.enter-chat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
height: 40px;
|
||||
background: #1B67EC;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
color: #757575;
|
||||
background: #11336E;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-browser {
|
||||
#nickname {
|
||||
input {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.enter-chat {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sideToolbarContainer {
|
||||
|
@ -411,6 +500,16 @@
|
|||
#chatconversation {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.touchmove-hack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -53,21 +53,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@mixin full-size-modal-positioner() {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@mixin full-size-modal-dialog() {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $verySmallScreen) {
|
||||
.welcome {
|
||||
display: block;
|
||||
|
@ -165,25 +150,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 480px) and (max-width: 580px) {
|
||||
.shift-right .focus-lock > div > div {
|
||||
@include full-size-modal-positioner();
|
||||
}
|
||||
|
||||
.shift-right .focus-lock [role="dialog"] {
|
||||
@include full-size-modal-dialog();
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 580px) and (max-width: 680px) {
|
||||
.mobile-browser {
|
||||
&.shift-right .focus-lock > div > div {
|
||||
@include full-size-modal-positioner();
|
||||
}
|
||||
|
||||
&.shift-right .focus-lock [role="dialog"] {
|
||||
@include full-size-modal-dialog();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -61,6 +61,7 @@
|
|||
"today": "Today"
|
||||
},
|
||||
"chat": {
|
||||
"enter": "Enter chat room",
|
||||
"error": "Error: your message was not sent. Reason: {{error}}",
|
||||
"fieldPlaceHolder": "Type your message here",
|
||||
"messagebox": "Type a message",
|
||||
|
|
|
@ -53,6 +53,11 @@ type Props = {
|
|||
*/
|
||||
hideCancelButton: boolean,
|
||||
|
||||
/**
|
||||
* If true, no footer will be displayed.
|
||||
*/
|
||||
disableFooter?: boolean,
|
||||
|
||||
i18n: Object,
|
||||
|
||||
/**
|
||||
|
@ -174,6 +179,10 @@ class StatelessDialog extends Component<Props> {
|
|||
this._renderCancelButton()
|
||||
].filter(Boolean);
|
||||
|
||||
if (this.props.disableFooter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFooter showKeyline = { propsFromModalFooter.showKeyline } >
|
||||
{
|
||||
|
|
|
@ -11,6 +11,16 @@ export function isMobileBrowser() {
|
|||
return Platform.OS === 'android' || Platform.OS === 'ios';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether or not the current environment is an ios mobile device.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isIosMobileBrowser() {
|
||||
return Platform.OS === 'ios';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the chrome extensions defined in the config file are installed or not.
|
||||
*
|
||||
|
|
|
@ -73,6 +73,7 @@ export { default as IconOpenInNew } from './open_in_new.svg';
|
|||
export { default as IconOutlook } from './office365.svg';
|
||||
export { default as IconPhone } from './phone.svg';
|
||||
export { default as IconPin } from './enlarge.svg';
|
||||
export { default as IconPlane } from './paper-plane.svg';
|
||||
export { default as IconPresentation } from './presentation.svg';
|
||||
export { default as IconRaisedHand } from './raised-hand.svg';
|
||||
export { default as IconRec } from './rec.svg';
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.6667 1.66663L1.66669 10.8333L7.6326 11.8276L8.33335 17.0833L10.644 13.2323L16.6667 17.9166V1.66663ZM8.73722 10.3221L6.35041 9.92426L15 4.63839V14.5089L11.3161 11.6436L12.5 7.49996L8.73722 10.3221Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 369 B |
|
@ -14,8 +14,10 @@ import ChatDialog from './ChatDialog';
|
|||
import Header from './ChatDialogHeader';
|
||||
import ChatInput from './ChatInput';
|
||||
import DisplayNameForm from './DisplayNameForm';
|
||||
import KeyboardAvoider from './KeyboardAvoider';
|
||||
import MessageContainer from './MessageContainer';
|
||||
import MessageRecipient from './MessageRecipient';
|
||||
import TouchmoveHack from './TouchmoveHack';
|
||||
|
||||
/**
|
||||
* React Component for holding the chat feature in a side panel that slides in
|
||||
|
@ -112,13 +114,16 @@ class Chat extends AbstractChat<Props> {
|
|||
_renderChat() {
|
||||
return (
|
||||
<>
|
||||
<MessageContainer
|
||||
messages = { this.props._messages }
|
||||
ref = { this._messageContainerRef } />
|
||||
<TouchmoveHack isModal = { this.props._isModal }>
|
||||
<MessageContainer
|
||||
messages = { this.props._messages }
|
||||
ref = { this._messageContainerRef } />
|
||||
</TouchmoveHack>
|
||||
<MessageRecipient />
|
||||
<ChatInput
|
||||
onResize = { this._onChatInputResize }
|
||||
onSend = { this._onSendMessage } />
|
||||
<KeyboardAvoider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ function ChatDialog({ children }: Props) {
|
|||
<Dialog
|
||||
customHeader = { Header }
|
||||
disableEnter = { true }
|
||||
disableFooter = { true }
|
||||
hideCancelButton = { true }
|
||||
submitDisabled = { true }
|
||||
titleKey = 'chat.title'>
|
||||
|
|
|
@ -6,6 +6,7 @@ import TextareaAutosize from 'react-textarea-autosize';
|
|||
import type { Dispatch } from 'redux';
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { Icon, IconPlane } from '../../../base/icons';
|
||||
import { connect } from '../../../base/redux';
|
||||
|
||||
import SmileysPanel from './SmileysPanel';
|
||||
|
@ -81,6 +82,7 @@ class ChatInput extends Component<Props, State> {
|
|||
this._onDetectSubmit = this._onDetectSubmit.bind(this);
|
||||
this._onMessageChange = this._onMessageChange.bind(this);
|
||||
this._onSmileySelect = this._onSmileySelect.bind(this);
|
||||
this._onSubmitMessage = this._onSubmitMessage.bind(this);
|
||||
this._onToggleSmileysPanel = this._onToggleSmileysPanel.bind(this);
|
||||
this._setTextAreaRef = this._setTextAreaRef.bind(this);
|
||||
}
|
||||
|
@ -109,30 +111,39 @@ class ChatInput extends Component<Props, State> {
|
|||
? 'show-smileys' : 'hide-smileys'} smileys-panel`;
|
||||
|
||||
return (
|
||||
<div id = 'chat-input' >
|
||||
<div className = 'smiley-input'>
|
||||
<div id = 'smileysarea'>
|
||||
<div id = 'smileys'>
|
||||
<Emoji
|
||||
onClick = { this._onToggleSmileysPanel }
|
||||
text = ':)' />
|
||||
<div className = { `chat-input-container${this.state.message.trim().length ? ' populated' : ''}` }>
|
||||
<div id = 'chat-input' >
|
||||
<div className = 'smiley-input'>
|
||||
<div id = 'smileysarea'>
|
||||
<div id = 'smileys'>
|
||||
<Emoji
|
||||
onClick = { this._onToggleSmileysPanel }
|
||||
text = ':)' />
|
||||
</div>
|
||||
</div>
|
||||
<div className = { smileysPanelClassName }>
|
||||
<SmileysPanel
|
||||
onSmileySelect = { this._onSmileySelect } />
|
||||
</div>
|
||||
</div>
|
||||
<div className = { smileysPanelClassName }>
|
||||
<SmileysPanel
|
||||
onSmileySelect = { this._onSmileySelect } />
|
||||
<div className = 'usrmsg-form'>
|
||||
<TextareaAutosize
|
||||
id = 'usermsg'
|
||||
inputRef = { this._setTextAreaRef }
|
||||
maxRows = { 5 }
|
||||
onChange = { this._onMessageChange }
|
||||
onHeightChange = { this.props.onResize }
|
||||
onKeyDown = { this._onDetectSubmit }
|
||||
placeholder = { this.props.t('chat.messagebox') }
|
||||
value = { this.state.message } />
|
||||
</div>
|
||||
<div className = 'send-button-container'>
|
||||
<div
|
||||
className = 'send-button'
|
||||
onClick = { this._onSubmitMessage }>
|
||||
<Icon src = { IconPlane } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className = 'usrmsg-form'>
|
||||
<TextareaAutosize
|
||||
id = 'usermsg'
|
||||
inputRef = { this._setTextAreaRef }
|
||||
maxRows = { 5 }
|
||||
onChange = { this._onMessageChange }
|
||||
onHeightChange = { this.props.onResize }
|
||||
onKeyDown = { this._onDetectSubmit }
|
||||
placeholder = { this.props.t('chat.messagebox') }
|
||||
value = { this.state.message } />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -148,6 +159,24 @@ class ChatInput extends Component<Props, State> {
|
|||
this._textArea && this._textArea.focus();
|
||||
}
|
||||
|
||||
|
||||
_onSubmitMessage: () => void;
|
||||
|
||||
/**
|
||||
* Submits the message to the chat window.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSubmitMessage() {
|
||||
const trimmed = this.state.message.trim();
|
||||
|
||||
if (trimmed) {
|
||||
this.props.onSend(trimmed);
|
||||
|
||||
this.setState({ message: '' });
|
||||
}
|
||||
|
||||
}
|
||||
_onDetectSubmit: (Object) => void;
|
||||
|
||||
/**
|
||||
|
@ -163,13 +192,7 @@ class ChatInput extends Component<Props, State> {
|
|||
&& event.shiftKey === false) {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmed = this.state.message.trim();
|
||||
|
||||
if (trimmed) {
|
||||
this.props.onSend(trimmed);
|
||||
|
||||
this.setState({ message: '' });
|
||||
}
|
||||
this._onSubmitMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ import { translate } from '../../../base/i18n';
|
|||
import { connect } from '../../../base/redux';
|
||||
import { updateSettings } from '../../../base/settings';
|
||||
|
||||
import KeyboardAvoider from './KeyboardAvoider';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@DisplayNameForm}.
|
||||
*/
|
||||
|
@ -70,16 +72,24 @@ class DisplayNameForm extends Component<Props, State> {
|
|||
|
||||
return (
|
||||
<div id = 'nickname'>
|
||||
<span>{ this.props.t('chat.nickname.title') }</span>
|
||||
<form onSubmit = { this._onSubmit }>
|
||||
<FieldTextStateless
|
||||
autoFocus = { true }
|
||||
compact = { true }
|
||||
id = 'nickinput'
|
||||
label = { t('chat.nickname.title') }
|
||||
onChange = { this._onDisplayNameChange }
|
||||
placeholder = { t('chat.nickname.popover') }
|
||||
shouldFitContainer = { true }
|
||||
type = 'text'
|
||||
value = { this.state.displayName } />
|
||||
</form>
|
||||
<div
|
||||
className = { `enter-chat${this.state.displayName.trim() ? '' : ' disabled'}` }
|
||||
onClick = { this._onSubmit }>
|
||||
{ t('chat.enter') }
|
||||
</div>
|
||||
<KeyboardAvoider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
// @flow
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { isIosMobileBrowser } from '../../../base/environment/utils';
|
||||
|
||||
const Avoider = styled.div`
|
||||
height: ${props => props.elementHeight}px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Component that renders an element to lift the chat input above the Safari keyboard,
|
||||
* computing the appropriate height comparisons based on the {@code visualViewport}.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function KeyboardAvoider() {
|
||||
if (!isIosMobileBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ elementHeight, setElementHeight ] = useState(0);
|
||||
const [ storedHeight, setStoredHeight ] = useState(window.innerHeight);
|
||||
|
||||
/**
|
||||
* Handles the resizing of the visual viewport in order to compute
|
||||
* the {@code KeyboardAvoider}'s height.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleViewportResize() {
|
||||
const { innerWidth, visualViewport: { width, height } } = window;
|
||||
|
||||
// Compare the widths to make sure the {@code visualViewport} didn't resize due to zooming.
|
||||
if (width === innerWidth) {
|
||||
if (height < storedHeight) {
|
||||
setElementHeight(storedHeight - height);
|
||||
} else {
|
||||
setElementHeight(0);
|
||||
}
|
||||
setStoredHeight(height);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Call the handler in case the keyboard is open when the {@code KeyboardAvoider} is mounted.
|
||||
handleViewportResize();
|
||||
|
||||
window.visualViewport.addEventListener('resize', handleViewportResize);
|
||||
|
||||
return () => {
|
||||
window.visualViewport.removeEventListener('resize', handleViewportResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Avoider elementHeight = { elementHeight } />;
|
||||
}
|
||||
|
||||
export default KeyboardAvoider;
|
|
@ -0,0 +1,67 @@
|
|||
// @flow
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { isMobileBrowser } from '../../../base/environment/utils';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* The component(s) that need to be scrollable on mobile.
|
||||
*/
|
||||
children: React$Node,
|
||||
|
||||
/**
|
||||
* Whether the component is rendered within a modal.
|
||||
*/
|
||||
isModal: boolean
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Component that disables {@code touchmove} propagation below it.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function TouchmoveHack({ children, isModal }: Props) {
|
||||
if (!isModal || !isMobileBrowser()) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const touchMoveElementRef = useRef(null);
|
||||
|
||||
/**
|
||||
* Atlaskit's {@code Modal} uses a third party library to disable touchmove events
|
||||
* which makes scrolling inside dialogs impossible. We therefore employ this hack
|
||||
* to intercept and stop the propagation of touchmove events from this wrapper that
|
||||
* is placed around the chat conversation from the {@code ChatDialog}.
|
||||
*
|
||||
* @param {Event} event - The touchmove event fired within the component.
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (touchMoveElementRef && touchMoveElementRef.current) {
|
||||
touchMoveElementRef.current.addEventListener('touchmove', handleTouchMove, true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (touchMoveElementRef && touchMoveElementRef.current) {
|
||||
touchMoveElementRef.current.removeEventListener('touchmove', handleTouchMove, true);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = 'touchmove-hack'
|
||||
ref = { touchMoveElementRef }>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TouchmoveHack;
|
Loading…
Reference in New Issue