0

chat fixes v3 or 5 or 123 (#168)

* only consider short-heights when not smallscreen; hide status bar when small screen, but leave shadow;

* fix max char counting bugs with paste, yet be still be able to use modifier keys even when max chars reached

* rmeove 'chat' button; move into textarea

* use image for emoji picker for sizing consitency

* cleanup unused things

* - totally unecessary emoji picker style improvements
- totally necessary doctype added to emoji picker so it shows up more stable-y on mobile views

* more stable layout positioning for chat panel without hacky margins, so that the bottom of the message list will always be on top of the form input, and not behind it at any point.

* hide header on touch screens when screns are small and screen height is short (possibly when keyboard is up), so that there's more visibliity to see messages. this only works on chrome, not ios safari right now, due to the position: fixed of things.

* move char counting to keyup instead

* address message text horiz overflow (#157)

* dont jumpToBottom if user has scrolled about 200px from the bottom (#101)

* scroll to bottom on resize too

* cleanup

* revert test bool

* typo

* re-readjust short-wide case again

* - add focus to input field after emoji is selected, put cursor at end
- instead of smooth scrolling to bottom, just jump there.
This commit is contained in:
gingervitis 2020-09-21 20:11:09 -07:00 committed by GitHub
parent f2f5993e22
commit 661eedc03a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 232 additions and 130 deletions

BIN
webroot/img/smiley.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,3 +1,4 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
@ -46,7 +47,7 @@
<link rel="preconnect" href="https://unpkg.com/htm?module" />
</head>
<body class="bg-gray-300 text-gray-800">
<body class="scrollbar-hidden bg-gray-300 text-gray-800">
<div id="app"></div>
<script type="module">

View File

@ -7,7 +7,7 @@ import SocialIcon from './components/social.js';
import UsernameForm from './components/chat/username.js';
import Chat from './components/chat/chat.js';
import Websocket from './utils/websocket.js';
import { secondsToHMMSS } from './utils/helpers.js';
import { secondsToHMMSS, hasTouchScreen } from './utils/helpers.js';
import {
addNewlines,
@ -69,6 +69,8 @@ export default class App extends Component {
windowHeight: window.innerHeight,
};
this.hasTouchScreen = hasTouchScreen();
// timers
this.playerRestartTimer = null;
this.offlineTimer = null;
@ -79,7 +81,7 @@ export default class App extends Component {
// misc dom events
this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this);
this.handleUsernameChange = this.handleUsernameChange.bind(this);
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 400);
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 250);
this.handleOfflineMode = this.handleOfflineMode.bind(this);
this.handleOnlineMode = this.handleOnlineMode.bind(this);
@ -101,7 +103,9 @@ export default class App extends Component {
componentDidMount() {
this.getConfig();
window.addEventListener('resize', this.handleWindowResize);
if (this.hasTouchScreen) {
window.addEventListener('orientationchange', this.handleWindowResize);
}
this.player = new OwncastPlayer();
this.player.setupPlayerCallbacks({
onReady: this.handlePlayerReady,
@ -120,6 +124,9 @@ export default class App extends Component {
clearTimeout(this.disableChatTimer);
clearInterval(this.streamDurationTimer);
window.removeEventListener('resize', this.handleWindowResize);
if (this.hasTouchScreen) {
window.removeEventListener('orientationchange', this.handleWindowResize);
}
}
// fetch /config data
@ -404,12 +411,14 @@ export default class App extends Component {
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE;
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
const extraAppClasses = classNames({
chat: displayChat,
'no-chat': !displayChat,
'single-col': singleColMode,
'bg-gray-800': singleColMode && displayChat,
'short-wide': shortHeight,
'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL,
'touch-screen': this.hasTouchScreen,
});
return html`

View File

@ -3,10 +3,17 @@ import htm from 'https://unpkg.com/htm?module';
const html = htm.bind(h);
import { EmojiButton } from 'https://cdn.skypack.dev/pin/@joeattardi/emoji-button@v4.1.0-v8psdkkxts3LNdpA0m5Q/min/@joeattardi/emoji-button.js';
import ContentEditable from './content-editable.js';
import ContentEditable, { replaceCaret } from './content-editable.js';
import { generatePlaceholderText, getCaretPosition, convertToText, convertOnPaste } from '../../utils/chat.js';
import { getLocalStorage, setLocalStorage } from '../../utils/helpers.js';
import { URL_CUSTOM_EMOJIS, KEY_CHAT_FIRST_MESSAGE_SENT } from '../../utils/constants.js';
import { getLocalStorage, setLocalStorage, classNames } from '../../utils/helpers.js';
import {
URL_CUSTOM_EMOJIS,
KEY_CHAT_FIRST_MESSAGE_SENT,
CHAT_MAX_MESSAGE_LENGTH,
CHAT_CHAR_COUNT_BUFFER,
CHAT_OK_KEYCODES,
CHAT_KEY_MODIFIERS,
} from '../../utils/constants.js';
export default class ChatInput extends Component {
constructor(props, context) {
@ -15,16 +22,16 @@ export default class ChatInput extends Component {
this.emojiPickerButton = createRef();
this.messageCharCount = 0;
this.maxMessageLength = 500;
this.maxMessageBuffer = 20;
this.emojiPicker = null;
this.prepNewLine = false;
this.modifierKeyPressed = false; // control/meta/shift/alt
this.state = {
inputHTML: '',
inputWarning: '',
inputText: '', // for counting
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH,
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
};
@ -56,11 +63,11 @@ export default class ChatInput extends Component {
.then(json => {
this.emojiPicker = new EmojiButton({
zIndex: 100,
theme: 'dark',
theme: 'owncast', // see chat.css
custom: json,
initialCategory: 'custom',
showPreview: false,
emojiSize: '30px',
emojiSize: '24px',
position: 'right-start',
strategy: 'absolute',
});
@ -93,6 +100,12 @@ export default class ChatInput extends Component {
this.setState({
inputHTML: inputHTML + content,
});
// a hacky way add focus back into input field
setTimeout( () => {
const input = this.formMessageInput.current;
input.focus();
replaceCaret(input);
}, 100);
}
// autocomplete user names
@ -133,23 +146,12 @@ export default class ChatInput extends Component {
}
handleMessageInputKeydown(event) {
const okCodes = [
'ArrowLeft',
'ArrowUp',
'ArrowRight',
'ArrowDown',
'Shift',
'Meta',
'Alt',
'Delete',
'Backspace',
];
const formField = this.formMessageInput.current;
let textValue = formField.innerText.trim(); // get this only to count chars
let numCharsLeft = this.maxMessageLength - textValue.length;
const key = event.key;
const newStates = {};
let numCharsLeft = CHAT_MAX_MESSAGE_LENGTH - textValue.length;
const key = event && event.key;
if (key === 'Enter') {
if (!this.prepNewLine) {
@ -159,6 +161,10 @@ export default class ChatInput extends Component {
return;
}
}
// allow key presses such as command/shift/meta, etc even when message length is full later.
if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = true;
}
if (key === 'Control' || key === 'Shift') {
this.prepNewLine = true;
}
@ -168,38 +174,52 @@ export default class ChatInput extends Component {
// value could have been changed, update char count
textValue = formField.innerText.trim();
numCharsLeft = this.maxMessageLength - textValue.length;
numCharsLeft = CHAT_MAX_MESSAGE_LENGTH - textValue.length;
}
}
// text count
if (numCharsLeft <= this.maxMessageBuffer) {
this.setState({
inputWarning: `${numCharsLeft} chars left`,
});
if (numCharsLeft <= 0 && !okCodes.includes(key)) {
if (numCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) {
newStates.inputText = textValue;
this.setState(newStates);
if (!this.modifierKeyPressed) {
event.preventDefault(); // prevent typing more
return;
}
} else {
this.setState({
inputWarning: '',
});
return;
}
newStates.inputText = textValue;
this.setState(newStates);
}
handleMessageInputKeyup(event) {
if (event.key === 'Control' || event.key === 'Shift') {
const formField = this.formMessageInput.current;
const textValue = formField.innerText.trim(); // get this only to count chars
const { key } = event;
if (key === 'Control' || key === 'Shift') {
this.prepNewLine = false;
}
if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = false;
}
this.setState({
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH - textValue.length,
});
}
handleMessageInputBlur(event) {
this.prepNewLine = false;
this.modifierKeyPressed = false;
}
handlePaste(event) {
// don't allow paste if too much text already
if (CHAT_MAX_MESSAGE_LENGTH - this.state.inputText.length < 0) {
event.preventDefault();
return;
}
convertOnPaste(event);
this.handleMessageInputKeydown(event);
}
handleSubmitChatButton(event) {
@ -209,11 +229,15 @@ export default class ChatInput extends Component {
sendMessage() {
const { handleSendMessage } = this.props;
const { hasSentFirstChatMessage, inputHTML } = this.state;
const { hasSentFirstChatMessage, inputHTML, inputText } = this.state;
if (CHAT_MAX_MESSAGE_LENGTH - inputText.length < 0) {
return;
}
const message = convertToText(inputHTML);
const newStates = {
inputWarning: '',
inputHTML: '',
inputText: '',
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH,
};
handleSendMessage(message);
@ -232,57 +256,51 @@ export default class ChatInput extends Component {
}
render(props, state) {
const { hasSentFirstChatMessage, inputWarning, inputHTML } = state;
const { hasSentFirstChatMessage, inputCharsLeft, inputHTML } = state;
const { inputEnabled } = props;
const emojiButtonStyle = {
display: this.emojiPicker ? 'block' : 'none',
display: this.emojiPicker && inputCharsLeft > 0 ? 'block' : 'none',
};
const extraClasses = classNames({
'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER,
});
const placeholderText = generatePlaceholderText(inputEnabled, hasSentFirstChatMessage);
return (
html`
<div id="message-input-container" class="fixed bottom-0 shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4 z-20">
<div id="message-input-container" class="relative shadow-md bg-gray-900 border-t border-gray-700 border-solid p-4 z-20 ${extraClasses}">
<${ContentEditable}
id="message-input"
class="appearance-none block w-full bg-gray-200 text-sm text-gray-700 border border-black-500 rounded py-2 px-2 my-2 focus:bg-white h-20 overflow-auto"
<div
id="message-input-wrap"
class="flex flex-row justify-end appearance-none w-full bg-gray-200 border border-black-500 rounded py-2 px-2 pr-12 my-2 overflow-auto">
<${ContentEditable}
id="message-input"
class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none"
placeholderText=${placeholderText}
innerRef=${this.formMessageInput}
html=${inputHTML}
disabled=${!inputEnabled}
onChange=${this.handleContentEditableChange}
onKeyDown=${this.handleMessageInputKeydown}
onKeyUp=${this.handleMessageInputKeyup}
onBlur=${this.handleMessageInputBlur}
placeholderText=${placeholderText}
innerRef=${this.formMessageInput}
html=${inputHTML}
disabled=${!inputEnabled}
onChange=${this.handleContentEditableChange}
onKeyDown=${this.handleMessageInputKeydown}
onKeyUp=${this.handleMessageInputKeyup}
onBlur=${this.handleMessageInputBlur}
onPaste=${this.handlePaste}
/>
<div id="message-form-actions" class="flex flex-row justify-between items-center w-full">
<span id="message-form-warning" class="text-red-600 text-xs">${inputWarning}</span>
<div id="message-form-actions-buttons" class="flex flex-row justify-end items-center">
onPaste=${this.handlePaste}
/>
</div>
<div id="message-form-actions" class="absolute flex flex-col w-10 justify-end items-center">
<button
ref=${this.emojiPickerButton}
id="emoji-button"
class="mr-2 text-2xl cursor-pointer"
class="text-3xl leading-3 cursor-pointer text-purple-600"
type="button"
style=${emojiButtonStyle}
onclick=${this.handleEmojiButtonClick}
disabled=${!inputEnabled}
>😏</button>
><img src="../../../img/smiley.png" /></button>
<button
onclick=${this.handleSubmitChatButton}
disabled=${!inputEnabled}
type="button"
id="button-submit-message"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded"
> Chat
</button>
<span id="message-form-warning" class="text-red-600 text-xs">${inputCharsLeft}/${CHAT_MAX_MESSAGE_LENGTH}</span>
</div>
</div>
</div>
`);
}

View File

@ -5,9 +5,9 @@ const html = htm.bind(h);
import Message from './message.js';
import ChatInput from './chat-input.js';
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
import { setVHvar, hasTouchScreen, jumpToBottom } from '../../utils/helpers.js';
import { jumpToBottom, debounce } from '../../utils/helpers.js';
import { extraUserNamesFromMessageHistory } from '../../utils/chat.js';
import { URL_CHAT_HISTORY } from '../../utils/constants.js';
import { URL_CHAT_HISTORY, MESSAGE_JUMPTOBOTTOM_BUFFER } from '../../utils/constants.js';
export default class Chat extends Component {
constructor(props, context) {
@ -29,17 +29,13 @@ export default class Chat extends Component {
this.submitChat = this.submitChat.bind(this);
this.submitChat = this.submitChat.bind(this);
this.scrollToBottom = this.scrollToBottom.bind(this);
this.jumpToBottomPending = false;
this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 500);
}
componentDidMount() {
this.setupWebSocketCallbacks();
this.getChatHistory();
if (hasTouchScreen()) {
// setVHvar();
// window.addEventListener("orientationchange", setVHvar);
}
window.addEventListener('resize', this.handleWindowResize);
}
componentDidUpdate(prevProps, prevState) {
@ -55,14 +51,13 @@ export default class Chat extends Component {
}
// scroll to bottom of messages list when new ones come in
if (messages.length > prevMessages.length) {
this.jumpToBottomPending = true;
if (!prevMessages.length || this.checkShouldScroll()) {
this.scrollToBottom();
}
}
}
componentWillUnmount() {
if (hasTouchScreen()) {
window.removeEventListener("orientationchange", setVHvar);
}
window.removeEventListener('resize', this.handleWindowResize);
}
setupWebSocketCallbacks() {
@ -168,6 +163,17 @@ export default class Chat extends Component {
jumpToBottom(this.scrollableMessagesContainer.current);
}
checkShouldScroll() {
const { scrollTop, scrollHeight, clientHeight } = this.scrollableMessagesContainer.current;
const fullyScrolled = scrollHeight - clientHeight;
return scrollHeight >= clientHeight && fullyScrolled - scrollTop < MESSAGE_JUMPTOBOTTOM_BUFFER;
}
handleWindowResize() {
this.scrollToBottom();
}
render(props, state) {
const { username, messagesOnly, chatInputEnabled } = props;
const { messages, chatUserNames } = state;
@ -181,20 +187,12 @@ export default class Chat extends Component {
/>`
);
// After the render completes (based on requestAnimationFrame) then jump to bottom.
// This hopefully fixes the race conditions where jumpTobottom fires before the
// DOM element has re-drawn with its new size.
if (this.jumpToBottomPending) {
this.jumpToBottomPending = false;
window.requestAnimationFrame(this.scrollToBottom);
}
if (messagesOnly) {
return html`
<div
id="messages-container"
ref=${this.scrollableMessagesContainer}
class="py-1 overflow-auto"
class="scrollbar-hidden py-1 overflow-auto"
>
${messageList}
</div>
@ -210,7 +208,7 @@ export default class Chat extends Component {
<div
id="messages-container"
ref=${this.scrollableMessagesContainer}
class="py-1 overflow-auto z-10"
class="scrollbar-hidden py-1 overflow-auto z-10"
>
${messageList}
</div>
@ -223,6 +221,5 @@ export default class Chat extends Component {
</section>
`;
}
}

View File

@ -8,7 +8,7 @@ https://stackoverflow.com/questions/22677931/react-js-onchange-event-for-content
*/
import { Component, createRef, h } from 'https://unpkg.com/preact?module';
function replaceCaret(el) {
export function replaceCaret(el) {
// Place the caret at the end of the element
const target = document.createTextNode('');
el.appendChild(target);
@ -69,8 +69,6 @@ export default class ContentEditable extends Component {
props.innerRef !== nextProps.innerRef;
}
componentDidUpdate() {
const el = this.getDOMElement();
if (!el) return;
@ -118,6 +116,7 @@ export default class ContentEditable extends Component {
this.el.current = current
} : innerRef || this.el,
onInput: this.emitChange,
onFocus: this.props.onFocus || this.emitChange,
onBlur: this.props.onBlur || this.emitChange,
onKeyup: this.props.onKeyUp || this.emitChange,
onKeydown: this.props.onKeyDown || this.emitChange,

View File

@ -34,7 +34,7 @@ export default class Message extends Component {
${author}
</div>
<div
class="message-text text-gray-300 font-normal"
class="message-text text-gray-300 font-normal overflow-y-hidden"
dangerouslySetInnerHTML=${
{ __html: formattedMessage }
}

View File

@ -27,7 +27,26 @@ export const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent';
export const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account necessary.';
export const CHAT_PLACEHOLDER_TEXT = 'Message';
export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.';
export const CHAT_MAX_MESSAGE_LENGTH = 500;
export const CHAT_CHAR_COUNT_BUFFER = 20;
export const CHAT_OK_KEYCODES = [
'ArrowLeft',
'ArrowUp',
'ArrowRight',
'ArrowDown',
'Shift',
'Meta',
'Alt',
'Delete',
'Backspace',
];
export const CHAT_KEY_MODIFIERS = [
'Control',
'Shift',
'Meta',
'Alt',
];
export const MESSAGE_JUMPTOBOTTOM_BUFFER = 260;
// app styling
export const WIDTH_SINGLE_COL = 730;

View File

@ -30,7 +30,7 @@ export function jumpToBottom(element) {
element.scrollTo({
top: element.scrollHeight,
left: 0,
behavior: 'smooth'
behavior: 'auto'
});
}, 50, element);
}

View File

@ -20,11 +20,12 @@ a:hover {
text-decoration: underline;
}
::-webkit-scrollbar {
.scrollbar-hidden::-webkit-scrollbar {
width: 0px;
background: transparent;
}
#app-container * {
transition: all .25s;
}
@ -48,6 +49,7 @@ button[disabled] {
header {
height: var(--header-height);
background-color: var(--header-bg-color);
display: block;
}
#logo-container {
@ -125,8 +127,6 @@ header {
height: calc((9 / 16) * var(--content-width));
}
.short-wide.chat #video-container {
height: calc(100vh - var(--header-height) - 3rem);
min-height: auto;
@ -151,15 +151,11 @@ header {
z-index: 40;
}
.single-col #chat-container {
position: relative;
position: fixed;
bottom: 0;
width: 100%;
height: auto;
min-height: 800px;
}
/* .single-col #video-container {
min-height: auto;
width: 100%;
} */
.single-col.chat #video-container,
.single-col.no-chat #video-container,
@ -180,14 +176,34 @@ header {
.single-col.chat #user-content {
display: none;
}
.single-col #messages-container {
flex-grow: 2;
margin-top: calc(var(--video-container-height));
}
.single-col #message-input-container {
width: 100%;
}
.single-col #message-input {
height: 3rem;
.single-col #stream-info {
height: 0;
padding: 0;
}
.single-col #stream-info span {
display: none;
}
.single-col #message-input-wrap {
max-height: 3em;
}
@media screen and (max-height: 500px) {
.single-col.touch-screen {
--header-height: 0px;
}
.single-col.touch-screen header {
display: none;
}
}
/* ************************************************8 */
@ -205,7 +221,4 @@ header {
font-size: 1rem;
}
#stream-info {
display: none;
}
}

View File

@ -14,13 +14,30 @@
width: var(--right-col-width);
}
#messages-container {
padding-bottom: 10rem;
#message-input-wrap {
min-height: 2.5rem;
max-height: 5rem;
}
#message-form-actions {
right: 2rem;
bottom: 1.88rem;
}
#emoji-button {
height: 1.75rem;
width: 1.75rem;
}
#message-form-warning {
display: none;
}
.display-count #message-form-warning {
display: block;
}
/******************************/
/******************************/
#message-input img {
display: inline;
vertical-align: middle;
@ -46,6 +63,8 @@
opacity: 1.0;
}
#message-input::selection { background:#d7ddf4; }
/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */
#message-input:disabled,
@ -56,10 +75,43 @@
/******************************/
/******************************/
/* EMOJI PICKER OVERRIDES */
.emoji-picker.owncast {
--secondary-text-color: rgba(255,255,255,.5);
--category-button-color: rgba(255,255,255,.5);
background: rgba(26,32,44,1); /* tailwind bg-gray-900 */
color: rgba(226,232,240,1); /* tailwind text-gray-300 */
border-color: black;
font-family: inherit;
}
.emoji-picker h2 {
font-family: inherit;
}
.emoji-picker__emoji {
border-radius: 5px;
}
.emoji-picker__emojis::-webkit-scrollbar {
background: transparent;
border-radius: 8px;
}
.emoji-picker__emojis::-webkit-scrollbar-track {
border-radius: 8px;
background-color: rgba(0,0,0,.2);
box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
}
.emoji-picker__emojis::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,.45);
border-radius: 8px;
}
/******************************/
.message-avatar {
height: 3.0em;
@ -77,6 +129,9 @@
/* MESSAGE TEXT HTML */
/* MESSAGE TEXT HTML */
/* MESSAGE TEXT HTML */
.message-text {
word-break: break-word;
}
.message-text a {
color: #7F9CF5; /* indigo-400 */
}
@ -102,15 +157,6 @@
padding: .25rem;
}
/* Hide emoji button on small screens */
@media screen and (max-width: 860px) {
#emoji-button {
/* Emoji library overrides this so important is needed */
display: none !important;
}
}
.message-text .chat-embed {
width: 100%;
border-radius: .25rem;