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:
Mihai-Andrei Uscat 2021-02-23 09:39:20 +02:00 committed by GitHub
parent 4c39d83ff1
commit 43761fc398
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 384 additions and 75 deletions

View File

@ -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();
}
}
}

View File

@ -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;
}
/**

View File

@ -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();
}
}
}

View File

@ -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",

View File

@ -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 } >
{

View File

@ -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.
*

View File

@ -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';

View File

@ -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

View File

@ -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 />
</>
);
}

View File

@ -24,6 +24,7 @@ function ChatDialog({ children }: Props) {
<Dialog
customHeader = { Header }
disableEnter = { true }
disableFooter = { true }
hideCancelButton = { true }
submitDisabled = { true }
titleKey = 'chat.title'>

View File

@ -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();
}
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;