Refactor mobile chat into modal (#3038)

* feat(mobile): refactor mobile chat into modal

- Make page always scrollable
- Move mobile chat into a standalone modal

* fix(test): split out mobile browser test specs

* fix(mobile): force chat button to render on top of footer

* fix: some small updates from review

* fix: hide/show hide chat menu option based on width

* fix: chat button icon getting cut off

* chore(tests): add browser tests for mobile chat modal

* chore(tests): add story for ChatModal component

* fix(test): quiet shellcheck

* fix: remove unused import

* fix(tests): silence storybook linting warning

* fix(ui): reposition chat modal button icon with transform
This commit is contained in:
Gabe Kangas 2023-05-22 18:56:44 -07:00 committed by GitHub
parent b9b569f3fe
commit 69f217f758
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 945 additions and 215 deletions

View File

@ -0,0 +1,16 @@
import { setup } from '../../support/setup.js';
import filterTests from '../../support/filterTests';
setup();
filterTests(['mobile'], () => {
describe(`Live mobile tests`, () => {
it('Can visit the page', () => {
cy.visit('http://localhost:8080');
});
it('Mobile chat button should not be visible', () => {
cy.get('#mobile-chat-button').should('not.exist');
});
});
});

View File

@ -1,5 +1,6 @@
import { setup } from '../../support/setup.js';
import fetchData from '../../support/fetchData.js';
import filterTests from '../../support/filterTests';
setup();
@ -12,73 +13,60 @@ describe(`Live tests`, () => {
cy.get('.vjs-big-play-button').should('be.visible');
});
// it('Chat should be visible', () => {
// cy.get('#chat-container').should('be.visible');
// });
it('User menu should be visible', () => {
cy.get('#user-menu').should('be.visible');
});
// it('Chat join message should exist', () => {
// cy.contains('joined the chat').should('be.visible');
// });
it('User menu should be visible', () => {
cy.get('#user-menu').should('be.visible');
});
it('Click on user menu', () => {
cy.get('#user-menu').click();
});
it('Can toggle chat off', () => {
cy.contains('Hide Chat').click();
});
it('Chat should not be visible', () => {
cy.get('#chat-container').should('not.exist');
});
it('Click on user menu', () => {
cy.get('#user-menu').click();
});
it('Can toggle chat on', () => {
cy.contains('Show Chat').click();
});
// it('Chat should be re-visible', () => {
// cy.get('#chat-container').should('be.visible');
// });
it('Click on user menu', () => {
cy.get('#user-menu').click();
});
it('Show change name modal', () => {
cy.contains('Change name').click();
});
it('Should change name', () => {
cy.get('#name-change-field').focus();
cy.get('#name-change-field').type('{ctrl+a}');
cy.get('#name-change-field').type('my-new-name');
cy.get('#name-change-submit').click();
cy.get('.ant-modal-close-x').click();
cy.wait(1500);
// cy.contains('is now known as').should('be.visible');
});
it('Should change to custom websocket host', () => {
fetchData('http://localhost:8080/api/admin/config/sockethostoverride', {
method: 'POST',
data: { value: 'ws://localhost:8080' },
});
cy.wait(1500);
});
it('Refresh page with new socket host', () => {
cy.visit('http://localhost:8080');
});
});
filterTests(['desktop'], () => {
describe(`Live desktop tests`, () => {
it('Click on user menu', () => {
cy.get('#user-menu').click();
});
it('Can toggle chat off', () => {
cy.contains('Hide Chat').click();
});
it('Chat should not be visible', () => {
cy.get('#chat-container').should('not.exist');
});
it('Click on user menu', () => {
cy.get('#user-menu').click();
});
it('Can toggle chat on', () => {
cy.contains('Show Chat').click();
});
it('Click on user menu', () => {
cy.get('#user-menu').click();
});
it('Show change name modal', () => {
cy.contains('Change name').click();
});
it('Should change name', () => {
cy.get('#name-change-field').focus();
cy.get('#name-change-field').type('{ctrl+a}');
cy.get('#name-change-field').type('my-new-name');
cy.get('#name-change-submit').click();
cy.get('.ant-modal-close-x').click();
cy.wait(1500);
// cy.contains('is now known as').should('be.visible');
});
it('Should change to custom websocket host', () => {
fetchData('http://localhost:8080/api/admin/config/sockethostoverride', {
method: 'POST',
data: { value: 'ws://localhost:8080' },
});
cy.wait(1500);
});
it('Refresh page with new socket host', () => {
cy.visit('http://localhost:8080');
});
});
});

View File

@ -0,0 +1,40 @@
import { setup } from '../../support/setup.js';
import filterTests from '../../support/filterTests';
setup();
filterTests(['mobile'], () => {
describe(`Live mobile tests`, () => {
it('Can visit the page', () => {
cy.visit('http://localhost:8080');
});
it('Mobile chat button should be visible', () => {
cy.get('#mobile-chat-button').should('be.visible');
});
it('Click mobile chat button', () => {
cy.get('#mobile-chat-button').click();
});
it('Mobile chat modal should be visible', () => {
cy.get('.ant-modal').should('be.visible');
});
it('Chat container should be visible', () => {
cy.get('#chat-container').should('be.visible');
});
it('Chat input should be visible', () => {
cy.get('#chat-input').should('be.visible');
});
it('Click on user menu', () => {
cy.get('#chat-modal-user-menu').click();
});
it('Show change name modal', () => {
cy.contains('Change name').click();
});
});
});

View File

@ -15,7 +15,6 @@ else
echo "Google Chrome not found. Using Electron."
fi
# Bundle the updated web code into the server codebase.
if [ -z "$SKIP_BUILD" ]; then
echo "Bundling web code into server..."
@ -30,7 +29,6 @@ else
echo "Skipping web build..."
fi
# Install the web test framework
if [ -z "$SKIP_BUILD" ]; then
echo "Installing test dependencies..."
@ -47,13 +45,13 @@ install_ffmpeg
start_owncast
# Run cypress browser tests for desktop
npx cypress run --browser "$BROWSER" --group "desktop-offline" --env tags=desktop --ci-build-id $BUILD_ID --tag "desktop,offline" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/offline/*.cy.js"
npx cypress run --parallel --browser "$BROWSER" --group "desktop-offline" --env tags=desktop --ci-build-id $BUILD_ID --tag "desktop,offline" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/offline/*.cy.js"
# Run cypress browser tests for mobile
npx cypress run --browser "$BROWSER" --group "mobile-offline" --ci-build-id $BUILD_ID --tag "mobile,offline" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/offline/*.cy.js" --config viewportWidth=375,viewportHeight=667
npx cypress run --parallel --browser "$BROWSER" --group "mobile-offline" --env tags=mobile --ci-build-id $BUILD_ID --tag "mobile,offline" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/offline/*.cy.js" --config viewportWidth=375,viewportHeight=667
start_stream
# Run cypress browser tests for desktop
npx cypress run --browser "$BROWSER" --group "desktop-online" --env tags=desktop --ci-build-id $BUILD_ID --tag "desktop,online" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/online/*.cy.js"
npx cypress run --parallel --browser "$BROWSER" --group "desktop-online" --env tags=desktop --ci-build-id $BUILD_ID --tag "desktop,online" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/online/*.cy.js"
# Run cypress browser tests for mobile
npx cypress run --browser "$BROWSER" --group "mobile-online" --ci-build-id $BUILD_ID --tag "mobile,online" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/online/*.cy.js" --config viewportWidth=375,viewportHeight=667
npx cypress run --parallel --browser "$BROWSER" --group "mobile-online" --env tags=mobile --ci-build-id $BUILD_ID --tag "mobile,online" --record --key e9c8b547-7a8f-452d-8c53-fd7531491e3b --spec "cypress/e2e/online/*.cy.js" --config viewportWidth=375,viewportHeight=667

View File

@ -5,7 +5,9 @@ set -o errexit
set -o pipefail
finish() {
# shellcheck disable=SC2317
kill_with_kids "$BROWSERSTACK_PID"
# shellcheck disable=SC2317
kill_with_kids "$STREAM_PID"
}

View File

@ -7,7 +7,6 @@
display: flex;
align-items: center;
height: 100%;
padding-bottom: 16px; // to match antd nav margin-bottom
button {
height: 100%;

View File

@ -29,6 +29,7 @@ export type ChatContainerProps = {
showInput?: boolean;
height?: string;
chatAvailable: boolean;
focusInput?: boolean;
};
function shouldCollapseMessages(
@ -93,6 +94,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
showInput,
height,
chatAvailable: chatEnabled,
focusInput = true,
}) => {
const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(false);
@ -282,7 +284,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
{MessagesTable}
{showInput && (
<div className={styles.chatTextField}>
<ChatTextField enabled={chatEnabled} />
<ChatTextField enabled={chatEnabled} focusInput={focusInput} />
</div>
)}
</div>

View File

@ -131,11 +131,12 @@ const getCharacterCount = node => {
export type ChatTextFieldProps = {
defaultText?: string;
enabled: boolean;
focusInput: boolean;
};
const characterLimit = 300;
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled }) => {
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
const [showEmojis, setShowEmojis] = useState(false);
const [characterCount, setCharacterCount] = useState(defaultText?.length);
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
@ -240,7 +241,7 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled })
};
return (
<div className={styles.root}>
<div id="chat-input" className={styles.root}>
<div
className={classNames(
styles.inputWrap,
@ -269,7 +270,7 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled })
style={{ width: '100%' }}
role="textbox"
aria-label="Chat text input"
autoFocus
autoFocus={focusInput}
/>
<Popover
content={

View File

@ -3,20 +3,31 @@
.root {
button {
border: none;
.ant-space {
.ant-space-item {
color: var(--theme-unknown-2);
}
}
.ant-space {
.ant-space-item {
color: var(--theme-unknown-2);
}
}
}
.username {
display: none;
.username {
display: inline;
@include screen(desktop) {
display: inline;
font-weight: 600;
font-size: .8rem;
}
@include screen(desktop) {
font-weight: 600;
font-size: 0.8rem;
}
}
.hideTitleOnMobile {
@include screen(mobile) {
display: none;
}
}
}
.chatToggle {
@include screen(mobile) {
display: none;
}
}

View File

@ -26,7 +26,7 @@ const Example = args => {
[],
);
return <UserDropdown {...args} />;
return <UserDropdown id="user-menu" {...args} />;
};
const Template: ComponentStory<typeof UserDropdown> = args => (

View File

@ -1,4 +1,5 @@
import { Menu, Dropdown, Button } from 'antd';
import classnames from 'classnames';
import { useRecoilState, useRecoilValue } from 'recoil';
import { FC, useState } from 'react';
@ -55,16 +56,29 @@ const AuthModal = dynamic(
);
export type UserDropdownProps = {
id: string;
username?: string;
hideTitleOnMobile?: boolean;
showToggleChatOption?: boolean;
};
export const UserDropdown: FC<UserDropdownProps> = ({ username: defaultUsername = undefined }) => {
export const UserDropdown: FC<UserDropdownProps> = ({
id,
username: defaultUsername = undefined,
hideTitleOnMobile = false,
showToggleChatOption: showHideChatOption = true,
}) => {
const [showNameChangeModal, setShowNameChangeModal] = useState<boolean>(false);
const [showAuthModal, setShowAuthModal] = useState<boolean>(false);
const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const toggleChatVisibility = () => {
// If we don't support the hide chat option then don't do anything.
if (!showHideChatOption) {
return;
}
setChatToggleVisible(!chatToggleVisible);
};
@ -97,12 +111,13 @@ export const UserDropdown: FC<UserDropdownProps> = ({ username: defaultUsername
<Menu.Item key="1" icon={<LockOutlined />} onClick={() => setShowAuthModal(true)}>
Authenticate
</Menu.Item>
{appState.chatAvailable && (
{showHideChatOption && appState.chatAvailable && (
<Menu.Item
key="3"
icon={<MessageOutlined />}
onClick={() => toggleChatVisibility()}
aria-expanded={chatToggleVisible}
className={styles.chatToggle}
>
{chatToggleVisible ? 'Hide Chat' : 'Show Chat'}
</Menu.Item>
@ -121,10 +136,17 @@ export const UserDropdown: FC<UserDropdownProps> = ({ username: defaultUsername
/>
)}
>
<div id="user-menu" className={styles.root}>
<div id={id} className={styles.root}>
<Dropdown overlay={menu} trigger={['click']}>
<Button type="primary" icon={<UserOutlined className={styles.userIcon} />}>
<span className={styles.username}>{username}</span>
<span
className={classnames([
styles.username,
hideTitleOnMobile && styles.hideTitleOnMobile,
])}
>
{username}
</span>
<CaretDownOutlined />
</Button>
</Dropdown>

View File

@ -4,7 +4,7 @@
// this margin is for fixed header
padding-top: var(--header-height);
background-color: var(--theme-color-main-background);
min-height: 100dvh;
min-height: 100vh;
position: relative;

View File

@ -171,11 +171,9 @@ export const Main: FC = () => {
<FatalErrorStateModal title={fatalError.title} message={fatalError.message} />
)}
{(!isMobile || !online) && (
<div className={styles.footerContainer}>
<Footer dynamicPaddingValue={dynamicFooterPadding} />
</div>
)}
<div className={styles.footerContainer}>
<Footer dynamicPaddingValue={dynamicFooterPadding} />
</div>
</Layout>
<Noscript />
</>

View File

@ -0,0 +1,12 @@
.modalWrapper {
// This is so modals and popovers spawned from this modal will
// be on top of it. Modals usually have a z-index of 1000 and popovers 800.
// We're getting into really hacky territory here. Don't judge, there
// is history behind this. Ask an elder to tell you tales.
z-index: 799;
top: unset;
}
.root {
// margin-top: 18vh;
}

View File

@ -0,0 +1,598 @@
/* eslint-disable object-shorthand */
import React, { useEffect } from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { ChatModal, ChatModalProps } from './ChatModal';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { CurrentUser } from '../../../interfaces/current-user';
import { currentUserAtom } from '../../stores/ClientConfigStore';
export default {
title: 'owncast/Chat/Chat modal',
component: ChatModal,
parameters: {
chromatic: { diffThreshold: 0.8 },
docs: {},
},
} as ComponentMeta<typeof ChatModal>;
const testMessages = `[
{
"type": "CHAT",
"id": "wY-MEXwnR",
"timestamp": "2022-04-28T20:30:27.001762726Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "UserDisplayName42",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "this is a test message"
},
{
"type": "CHAT",
"id": "VhLGEXwnR",
"timestamp": "2022-04-28T20:30:28.806999545Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Hit 3"
},
{
"type": "CHAT",
"id": "GguMEuw7R",
"timestamp": "2022-04-28T20:30:34.500150601Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Jkjk"
},
{
"type": "CHAT",
"id": "y_-VEXwnR",
"timestamp": "2022-04-28T20:31:32.695583044Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "I\\u0026#39;m doing alright. How about you Hatnix?"
},
{
"type": "CHAT",
"id": "qAaKEuwng",
"timestamp": "2022-04-28T20:34:16.22275314Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Oh shiet I didn\\u0026#39;t think you would kill him"
},
{
"type": "CHAT",
"id": "8wUFEuwnR",
"timestamp": "2022-04-28T20:34:21.624898714Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Hahaha, ruthless"
},
{
"type": "CHAT",
"id": "onYcPuQnR",
"timestamp": "2022-04-28T20:34:50.671024312Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "I\\u0026#39;ve never played it before"
},
{
"type": "CHAT",
"id": "kORyEXQ7R",
"timestamp": "2022-04-28T20:40:29.761977233Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "brb real quick"
},
{
"type": "CHAT",
"id": "F3DvsuQ7g",
"timestamp": "2022-04-28T20:50:29.451341783Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "I\\u0026#39;m back"
},
{
"type": "CHAT",
"id": "AH2vsXwnR",
"timestamp": "2022-04-28T20:50:33.872156152Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Whoa what happened here?"
},
{
"type": "CHAT",
"id": "xGkOsuw7R",
"timestamp": "2022-04-28T20:50:53.202147658Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Your dwarf was half naked."
},
{
"type": "CHAT",
"id": "opIdsuw7g",
"timestamp": "2022-04-28T20:50:59.631595947Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "lol"
},
{
"type": "CHAT",
"id": "JpwdsuQnR",
"timestamp": "2022-04-28T20:51:18.065535459Z",
"user": {
"id": "vbh9gtPng",
"displayName": "𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"displayColor": 276,
"createdAt": "2022-03-16T21:02:32.009965702Z",
"previousNames": [
"goth-volhard",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝒽𝒶𝓅𝓅𝓎 𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝒶𝓇𝒻𝒶𝒶𝓇𝑒𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™"
],
"nameChangedAt": "2022-04-14T21:51:50.97992512Z",
"scopes": [
""
]
},
"body": "evening did i just see you running around in... nothing"
},
{
"type": "CHAT",
"id": "R4WKsXw7R",
"timestamp": "2022-04-28T20:51:28.064914803Z",
"user": {
"id": "vbh9gtPng",
"displayName": "𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"displayColor": 276,
"createdAt": "2022-03-16T21:02:32.009965702Z",
"previousNames": [
"goth-volhard",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝒽𝒶𝓅𝓅𝓎 𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝒶𝓇𝒻𝒶𝒶𝓇𝑒𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™"
],
"nameChangedAt": "2022-04-14T21:51:50.97992512Z",
"scopes": [
""
]
},
"body": "^^"
},
{
"type": "CHAT",
"id": "g-PKyXw7g",
"timestamp": "2022-04-28T20:51:47.936500772Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "Lol Starfarer, so my eyes didnt deceive me."
},
{
"type": "CHAT",
"id": "fV8Ksuw7R",
"timestamp": "2022-04-28T20:51:49.588744112Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 329,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": [
"gifted-nobel",
"EliteMooseTaskForce"
],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": [
""
]
},
"body": "hahahaha"
},
{
"type": "CHAT",
"id": "TaStyuwnR",
"timestamp": "2022-04-28T20:52:38.127528579Z",
"user": {
"id": "vbh9gtPng",
"displayName": "𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"displayColor": 276,
"createdAt": "2022-03-16T21:02:32.009965702Z",
"previousNames": [
"goth-volhard",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝒽𝒶𝓅𝓅𝓎 𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝒶𝓇𝒻𝒶𝒶𝓇𝑒𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™"
],
"nameChangedAt": "2022-04-14T21:51:50.97992512Z",
"scopes": [
""
]
},
"body": "lol sounds nice"
},
{
"type": "CHAT",
"id": "JGposuwng",
"timestamp": "2022-04-28T20:53:49.329567087Z",
"user": {
"id": "GCa3J9P7R",
"displayName": "(ghost of)^10 * toudy49",
"displayColor": 147,
"createdAt": "2022-03-22T21:49:25.284237821Z",
"previousNames": [
"lucid-pike",
"toudy49",
"ghost of toudy49",
"ghost of ghost of toudy49",
"ghost of ghost of ghost of toudy49",
"ghost of ghost of ghost of ghost of toudy49",
"ghost of ghost of ghost of ghost of ghost of toudy49",
"ghost ofghost of ghost of ghost of ghost of ghost of toudy49",
"ghostof ghost of ghost of ghost of ghost of ghost of toudy49",
"(ghost of)^6 * toudy49",
"(ghost of)^7 * toudy49",
"(ghost of)^8 * toudy49",
"(ghost of)^9 * toudy49",
"(ghost of)^10 * toudy49"
],
"nameChangedAt": "2022-04-11T21:01:19.938445828Z",
"scopes": [
""
]
},
"body": "!hydrate"
},
{
"type": "CHAT",
"id": "T4tTsuwng",
"timestamp": "2022-04-28T20:53:49.391636551Z",
"user": {
"id": "fKINHKpnR",
"displayName": "hatnixbot",
"displayColor": 325,
"createdAt": "2021-11-24T08:11:32Z",
"previousNames": [
"hatnixbot"
],
"scopes": [
"CAN_SEND_SYSTEM_MESSAGES",
"CAN_SEND_MESSAGES",
"HAS_ADMIN_ACCESS"
]
},
"body": "test 123"
},
{
"type": "CHAT",
"id": "wUJTsuw7R",
"timestamp": "2022-04-28T20:53:54.073218761Z",
"user": {
"id": "GCa3J9P7R",
"displayName": "(ghost of)^10 * toudy49",
"displayColor": 147,
"createdAt": "2022-03-22T21:49:25.284237821Z",
"previousNames": [
"lucid-pike",
"toudy49",
"ghost of toudy49",
"ghost of ghost of toudy49",
"ghost of ghost of ghost of toudy49",
"ghost of ghost of ghost of ghost of toudy49",
"ghost of ghost of ghost of ghost of ghost of toudy49",
"ghost ofghost of ghost of ghost of ghost of ghost of toudy49",
"ghostof ghost of ghost of ghost of ghost of ghost of toudy49",
"(ghost of)^6 * toudy49",
"(ghost of)^7 * toudy49",
"(ghost of)^8 * toudy49",
"(ghost of)^9 * toudy49",
"(ghost of)^10 * toudy49"
],
"nameChangedAt": "2022-04-11T21:01:19.938445828Z",
"scopes": [
""
]
},
"body": "!stretch"
},
{
"id": "xDHBYL4Vgz",
"timestamp": "2022-10-05T01:50:08.178863235Z",
"type": "USER_JOINED",
"user": {
"id": "fg9tcCnVg",
"displayName": "brave-khorana",
"displayColor": 293,
"createdAt": "2022-09-25T15:27:35.444193966Z",
"previousNames": [
"brave-khorana"
],
"nameChangedAt": "0001-01-01T00:00:00Z",
"isBot": false,
"authenticated": false
}
},
{
"type": "CHAT",
"id": "S_Joyuw7R",
"timestamp": "2022-04-28T20:53:54.119778013Z",
"user": {
"id": "fKINHKpnR",
"displayName": "hatnixbot",
"displayColor": 325,
"createdAt": "2021-11-24T08:11:32Z",
"previousNames": [
"hatnixbot"
],
"scopes": [
"CAN_SEND_SYSTEM_MESSAGES",
"CAN_SEND_MESSAGES",
"HAS_ADMIN_ACCESS"
]
},
"body": "blah blah"
},
{
"body": "Bonjour gabe. What a pleasure to meet you.",
"id": "YZqhLYV4g",
"timestamp": "2022-10-05T01:47:13.909247665Z",
"type": "SYSTEM",
"user": {
"displayName": "Owncast TV"
}
},
{
"type": "CHAT",
"id": "MtYTyXwnR",
"timestamp": "2022-04-28T20:53:57.796985761Z",
"user": {
"id": "vbh9gtPng",
"displayName": "𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"displayColor": 276,
"createdAt": "2022-03-16T21:02:32.009965702Z",
"previousNames": [
"goth-volhard",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝒽𝒶𝓅𝓅𝓎 𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝒶𝓇𝒻𝒶𝒶𝓇𝑒𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™"
],
"nameChangedAt": "2022-04-14T21:51:50.97992512Z",
"scopes": [
""
]
},
"body": "heyy toudy"
},
{
"type": "CHAT",
"id": "MtYTyXwnR",
"timestamp": "2022-04-28T20:53:57.796985761Z",
"user": {
"id": "vbh9gtPng",
"displayName": "𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"displayColor": 276,
"createdAt": "2022-03-16T21:02:32.009965702Z",
"previousNames": [
"goth-volhard",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝒽𝒶𝓅𝓅𝓎 𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™",
"𝓈𝓉𝒶𝒶𝓇𝒻𝒶𝒶𝓇𝑒𝑒𝓇™",
"𝓈𝓉𝒶𝓇𝒻𝒶𝓇𝑒𝓇™"
],
"nameChangedAt": "2022-04-14T21:51:50.97992512Z",
"scopes": [
""
]
},
"body": "how is everyone?"
},
{
"body": "Gabe Test liked that this stream went live.",
"id": "FTprqf0VR",
"image": "https://media.mastodon.cloud/accounts/avatars/000/463/008/original/d0bc0971a54ffc75.jpg",
"link": "https://mastodon.cloud/users/gabektest",
"timestamp": "2023-02-05T17:49:36.619470844-08:00",
"title": "gabektest@mastodon.cloud",
"type": "FEDIVERSE_ENGAGEMENT_LIKE",
"user": {
"displayName": "New Owncast Server"
}
}
]`;
const messages: ChatMessage[] = JSON.parse(testMessages);
const currentUser = {
id: 'fKINHKpnR',
displayName: 'testuser',
displayColor: 2,
isModerator: true,
};
// This component uses Recoil internally so wrap it in a RecoilRoot.
const Component = args => {
const setCurrentUser = useSetRecoilState<CurrentUser>(currentUserAtom);
useEffect(() => setCurrentUser(currentUser), []);
return <ChatModal {...args} />;
};
const Template: ComponentStory<typeof ChatModal> = args => (
<RecoilRoot>
<Component {...args} />
</RecoilRoot>
);
export const Example = Template.bind({});
Example.args = {
loading: false,
messages: messages,
usernameToHighlight: 'testuser',
chatUserId: 'testuser',
isModerator: true,
showInput: true,
chatAvailable: true,
handleClose: () => {},
currentUser: currentUser,
} as ChatModalProps;

View File

@ -0,0 +1,66 @@
import { Modal } from 'antd';
import { FC } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import styles from './ChatModal.module.scss';
import { ComponentError } from '../../ui/ComponentError/ComponentError';
import { ChatContainer } from '../../chat/ChatContainer/ChatContainer';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { CurrentUser } from '../../../interfaces/current-user';
import { UserDropdown } from '../../common/UserDropdown/UserDropdown';
export type ChatModalProps = {
messages: ChatMessage[];
currentUser: CurrentUser;
handleClose: () => void;
};
export const ChatModal: FC<ChatModalProps> = ({ messages, currentUser, handleClose }) => {
if (!currentUser) {
return null;
}
const { id, displayName, isModerator } = currentUser;
const modalBodyStyle = {
padding: '0px',
height: '55vh',
};
return (
<ErrorBoundary
// eslint-disable-next-line react/no-unstable-nested-components
fallbackRender={({ error, resetErrorBoundary }) => (
<ComponentError
componentName="ChatModal"
message={error.message}
retryFunction={resetErrorBoundary}
/>
)}
>
<Modal
open
centered
maskClosable={false}
footer={null}
title={<UserDropdown id="chat-modal-user-menu" showToggleChatOption={false} />}
maskStyle={{
zIndex: 700,
}}
className={styles.root}
bodyStyle={modalBodyStyle}
wrapClassName={styles.modalWrapper}
onCancel={handleClose}
>
<ChatContainer
messages={messages}
usernameToHighlight={displayName}
chatUserId={id}
isModerator={isModerator}
chatAvailable
focusInput={false}
/>
</Modal>
</ErrorBoundary>
);
};

View File

@ -67,7 +67,7 @@ const ActionButtons: FC<ActionButtonProps> = ({
)}
</ActionButtonRow>
</div>
<div className={styles.mobileActionButtonMenu}>
<div className={styles.mobileActionButtons}>
{(supportsBrowserNotifications ||
supportsBrowserNotifications ||
externalActionButtons.length > 0) && (

View File

@ -13,20 +13,9 @@
bottom: 0;
width: 100%;
@include screen(tablet) {
top: 0;
@include screen(tablet) {
top: 0;
position: relative;
&.online {
position: absolute;
top: calc(var(--player-container-height) + var(--status-bar-height) + var(--header-height));
// As we want content in the tabs to scroll within itself, force the tabs to display max height at all times.
// (We don't have to do this when not-online because we're having the entire layout scroll.
:global(.ant-tabs-content) {
height: 100% !important;
}
}
}
:global(.ant-tabs-nav) {
@ -53,16 +42,22 @@
color: var(--theme-color-background-main);
}
.mobileActionButtonMenu {
.mobileActionButtons {
display: none;
@include screen(tablet) {
display: block;
display: flex;
align-items: center;
position: absolute;
top: 4px;
right: 10px;
z-index: 199;
}
> * {
margin-left: 5px;
margin-right: 5px;
}
}
.desktopActionButtons {
@ -109,3 +104,12 @@
.offlineBanner {
color: var(--theme-color-background-main);
}
.floatingMobileChatModalButton {
position: fixed;
width: 100px;
height: 40px;
bottom: 40px;
right: 10px;
font-weight: 600;
}

View File

@ -1,5 +1,6 @@
import { useRecoilState, useRecoilValue } from 'recoil';
import { Skeleton, Col, Row } from 'antd';
import { Skeleton, Col, Row, Button } from 'antd';
import MessageFilled from '@ant-design/icons/MessageFilled';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import classnames from 'classnames';
@ -11,7 +12,6 @@ import {
clientConfigStateAtom,
chatMessagesAtom,
currentUserAtom,
isChatAvailableSelector,
isChatVisibleSelector,
appStateAtom,
isOnlineSelector,
@ -32,6 +32,7 @@ import { ExternalAction } from '../../../interfaces/external-action';
import { Modal } from '../Modal/Modal';
import { DesktopContent } from './DesktopContent';
import { MobileContent } from './MobileContent';
import { ChatModal } from '../../modals/ChatModal/ChatModal';
// Lazy loaded components
@ -91,7 +92,6 @@ export const Content: FC = () => {
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector);
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const currentUser = useRecoilValue(currentUserAtom);
const serverStatus = useRecoilValue<ServerStatus>(serverStatusState);
const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom);
@ -124,6 +124,8 @@ export const Content: FC = () => {
const [supportsBrowserNotifications, setSupportsBrowserNotifications] = useState(false);
const supportFediverseFeatures = fediverseEnabled;
const [showChatModal, setShowChatModal] = useState(false);
const externalActionSelected = (action: ExternalAction) => {
const { openExternally, url } = action;
// apply openExternally only if we don't have an HTML embed
@ -262,12 +264,8 @@ export const Content: FC = () => {
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
messages={messages}
currentUser={currentUser}
showChat={showChat}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
chatEnabled={isChatAvailable}
online={online}
/>
) : (
@ -305,6 +303,24 @@ export const Content: FC = () => {
handleClose={() => setShowFollowModal(false)}
/>
</Modal>
{showChatModal && isChatVisible && (
<ChatModal
messages={messages}
currentUser={currentUser}
handleClose={() => setShowChatModal(false)}
/>
)}
{isChatVisible && (
<Button
id="mobile-chat-button"
type="primary"
onClick={() => setShowChatModal(true)}
className={styles.floatingMobileChatModalButton}
style={{ zIndex: 99 }}
>
Chat <MessageFilled style={{ transform: 'translateX(-1px)' }} />
</Button>
)}
</>
);
};

View File

@ -1,14 +1,12 @@
import React, { ComponentType, FC } from 'react';
import dynamic from 'next/dynamic';
import { Skeleton, TabsProps } from 'antd';
import { TabsProps } from 'antd';
import { ErrorBoundary } from 'react-error-boundary';
import classNames from 'classnames';
import { SocialLink } from '../../../interfaces/social-link.model';
import styles from './Content.module.scss';
import { CustomPageContent } from '../CustomPageContent/CustomPageContent';
import { ContentHeader } from '../../common/ContentHeader/ContentHeader';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { CurrentUser } from '../../../interfaces/current-user';
import { ComponentError } from '../ComponentError/ComponentError';
export type MobileContentProps = {
@ -19,10 +17,6 @@ export type MobileContentProps = {
extraPageContent: string;
setShowFollowModal: (show: boolean) => void;
supportFediverseFeatures: boolean;
messages: ChatMessage[];
currentUser: CurrentUser;
showChat: boolean;
chatEnabled: boolean;
online: boolean;
};
@ -42,20 +36,6 @@ const FollowerCollection = dynamic(
},
);
const ChatContainer = dynamic(
() => import('../../chat/ChatContainer/ChatContainer').then(mod => mod.ChatContainer),
{
ssr: false,
},
);
type ChatContentProps = {
showChat: boolean;
chatEnabled: boolean;
messages: ChatMessage[];
currentUser: CurrentUser;
};
const ComponentErrorFallback = ({ error, resetErrorBoundary }) => (
<ComponentError
message={error}
@ -64,32 +44,12 @@ const ComponentErrorFallback = ({ error, resetErrorBoundary }) => (
/>
);
const ChatContent: FC<ChatContentProps> = ({ showChat, chatEnabled, messages, currentUser }) => {
const { id, displayName } = currentUser;
return showChat && !!currentUser ? (
<ChatContainer
messages={messages}
usernameToHighlight={displayName}
chatUserId={id}
isModerator={false}
chatAvailable={chatEnabled}
/>
) : (
<Skeleton loading active paragraph={{ rows: 7 }} />
);
};
export const MobileContent: FC<MobileContentProps> = ({
name,
summary,
tags,
socialHandles,
extraPageContent,
messages,
currentUser,
showChat,
chatEnabled,
setShowFollowModal,
supportFediverseFeatures,
online,
@ -111,23 +71,10 @@ export const MobileContent: FC<MobileContentProps> = ({
);
const items = [];
if (showChat && currentUser) {
items.push({
label: 'Chat',
key: '0',
children: (
<ChatContent
showChat={showChat}
chatEnabled={chatEnabled}
messages={messages}
currentUser={currentUser}
/>
),
});
}
items.push({ label: 'About', key: '2', children: aboutTabContent });
items.push({ label: 'About', key: '0', children: aboutTabContent });
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
items.push({ label: 'Followers', key: '1', children: followersTabContent });
}
return (

View File

@ -1,5 +1,5 @@
import { Tooltip, Avatar } from 'antd';
import { FC } from 'react';
import { FC, useEffect, useState } from 'react';
import cn from 'classnames';
import dynamic from 'next/dynamic';
import Link from 'next/link';
@ -21,41 +21,51 @@ export type HeaderComponentProps = {
online: boolean;
};
export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisabled, online }) => (
<header className={cn([`${styles.header}`], 'global-header')}>
{online ? (
<Link href="#player" className={styles.skipLink}>
Skip to player
export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisabled, online }) => {
const [canHideChat, setCanHideChat] = useState(false);
useEffect(() => {
setCanHideChat(window.innerWidth >= 768);
}, []);
return (
<header className={cn([`${styles.header}`], 'global-header')}>
{online ? (
<Link href="#player" className={styles.skipLink}>
Skip to player
</Link>
) : (
<Link href="#offline-message" className={styles.skipLink}>
Skip to offline message
</Link>
)}
<Link href="#skip-to-content" className={styles.skipLink}>
Skip to page content
</Link>
) : (
<Link href="#offline-message" className={styles.skipLink}>
Skip to offline message
<Link href="#footer" className={styles.skipLink}>
Skip to footer
</Link>
)}
<Link href="#skip-to-content" className={styles.skipLink}>
Skip to page content
</Link>
<Link href="#footer" className={styles.skipLink}>
Skip to footer
</Link>
<div className={styles.logo}>
<div id="header-logo" className={styles.logoImage}>
<Avatar src="/logo" size="large" shape="circle" className={styles.avatar} />
<div className={styles.logo}>
<div id="header-logo" className={styles.logoImage}>
<Avatar src="/logo" size="large" shape="circle" className={styles.avatar} />
</div>
<h1 className={styles.title} id="global-header-text">
{name}
</h1>
</div>
<h1 className={styles.title} id="global-header-text">
{name}
</h1>
</div>
{chatAvailable && !chatDisabled && <UserDropdown />}
{!chatAvailable && !chatDisabled && (
<Tooltip
overlayClassName={styles.toolTip}
title="Chat will be available when the stream is live."
placement="left"
>
<span className={styles.chatOfflineText}>Chat is offline</span>
</Tooltip>
)}
</header>
);
{chatAvailable && !chatDisabled && (
<UserDropdown id="user-menu" hideTitleOnMobile showToggleChatOption={canHideChat} />
)}
{!chatAvailable && !chatDisabled && (
<Tooltip
overlayClassName={styles.toolTip}
title="Chat will be available when the stream is live."
placement="left"
>
<span className={styles.chatOfflineText}>Chat is offline</span>
</Tooltip>
)}
</header>
);
};
export default Header;