Merge branch 'develop' into fix/ImplementPasswordRules

This commit is contained in:
Jambaldorj Ochirpurev
2023-03-01 14:11:50 +01:00
committed by GitHub
371 changed files with 23954 additions and 2697 deletions

View File

@@ -151,7 +151,9 @@ export const CurrentVariantsTable: FC = () => {
dataIndex: 'cpuUsageLevel',
key: 'cpuUsageLevel',
render: (level: string, variant: VideoVariant) =>
!level || variant.videoPassthrough ? 'n/a' : ENCODER_PRESET_TOOLTIPS[level].split(' ')[0],
!level || variant.videoPassthrough
? 'n/a'
: ENCODER_PRESET_TOOLTIPS[level]?.split(' ')[0] || 'Warning: please edit & reset',
},
{
title: '',

View File

@@ -68,7 +68,7 @@ export const EditCustomJavascript: FC = () => {
setContent(initialContent);
}, [instanceDetails]);
const onCSSValueChange = React.useCallback(value => {
const onValueChange = React.useCallback(value => {
setContent(value);
if (value !== initialContent && !hasChanged) {
setHasChanged(true);
@@ -80,20 +80,18 @@ export const EditCustomJavascript: FC = () => {
return (
<div className="edit-custom-css">
<Title level={3} className="section-title">
Customize your page styling with CSS
Customize your page with Javascript
</Title>
<p className="description">
Customize the look and feel of your Owncast instance by overriding the CSS styles of various
components on the page. Refer to the{' '}
Insert custom Javascript into your Owncast page to add your own functionality or to add 3rd
party scripts. Read more about how to use this feature in the{' '}
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
CSS &amp; Components guide
Web page documentation.
</a>
.
</p>
<p className="description">
Please input plain CSS text, as this will be directly injected onto your page during load.
</p>
<p className="description">Please use raw Javascript, no HTML or any script tags.</p>
<CodeMirror
value={content}
@@ -101,7 +99,7 @@ export const EditCustomJavascript: FC = () => {
theme={bbedit}
height="200px"
extensions={[javascript()]}
onChange={onCSSValueChange}
onChange={onValueChange}
/>
<br />

View File

@@ -1,6 +1,5 @@
import React, { useState, useContext, useEffect } from 'react';
import { Button, Collapse, Typography, Tooltip } from 'antd';
import dynamic from 'next/dynamic';
import { Collapse, Typography } from 'antd';
import { TEXTFIELD_TYPE_NUMBER, TEXTFIELD_TYPE_PASSWORD, TEXTFIELD_TYPE_URL } from './TextField';
import { TextFieldWithSubmit } from './TextFieldWithSubmit';
import { ServerStatusContext } from '../../utils/server-status-context';
@@ -15,16 +14,6 @@ import {
import { UpdateArgs } from '../../types/config-section';
import { ResetYP } from './ResetYP';
// Lazy loaded components
const CopyOutlined = dynamic(() => import('@ant-design/icons/CopyOutlined'), {
ssr: false,
});
const RedoOutlined = dynamic(() => import('@ant-design/icons/RedoOutlined'), {
ssr: false,
});
const { Panel } = Collapse;
// eslint-disable-next-line react/function-component-definition
@@ -38,10 +27,6 @@ export default function EditInstanceDetails() {
const { adminPassword, ffmpegPath, rtmpServerPort, webServerPort, yp, socketHostOverride } =
serverConfig;
const [copyIsVisible, setCopyVisible] = useState(false);
const COPY_TOOLTIP_TIMEOUT = 3000;
useEffect(() => {
setFormDataValues({
adminPassword,
@@ -79,22 +64,6 @@ export default function EditInstanceDetails() {
}
};
function generateStreamKey() {
let key = '';
for (let i = 0; i < 3; i += 1) {
key += Math.random().toString(36).substring(2);
}
handleFieldChange({ fieldName: 'streamKey', value: key });
}
function copyStreamKey() {
navigator.clipboard.writeText(formDataValues.streamKey).then(() => {
setCopyVisible(true);
setTimeout(() => setCopyVisible(false), COPY_TOOLTIP_TIMEOUT);
});
}
return (
<div className="edit-server-details-container">
<div className="field-container field-streamkey-container">
@@ -108,18 +77,6 @@ export default function EditInstanceDetails() {
onChange={handleFieldChange}
onSubmit={showStreamKeyChangeMessage}
/>
<div className="streamkey-actions">
<Tooltip title="Generate a stream key">
<Button icon={<RedoOutlined />} size="small" onClick={generateStreamKey} />
</Tooltip>
<Tooltip
className="copy-tooltip"
title={copyIsVisible ? 'Copied!' : 'Copy to clipboard'}
>
<Button icon={<CopyOutlined />} size="small" onClick={copyStreamKey} />
</Tooltip>
</div>
</div>
</div>
<TextFieldWithSubmit

View File

@@ -1,5 +1,4 @@
import React, { FC, ReactNode, useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Link from 'next/link';
import Head from 'next/head';
import { differenceInSeconds } from 'date-fns';
@@ -224,7 +223,7 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
key: 'viewer-info',
},
!chatDisabled && {
label: <Link href="/admin/viewer-info">Chat &amp; Users</Link>,
label: <span>Chat &amp; Users</span>,
icon: <MessageOutlined />,
children: chatMenu,
key: 'chat-and-users',
@@ -338,7 +337,3 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
</Layout>
);
};
MainLayout.propTypes = {
children: PropTypes.element.isRequired,
};

View File

@@ -44,7 +44,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { rtmpServerPort } = serverConfig;
const { rtmpServerPort, streamKeyOverridden } = serverConfig;
const instanceUrl = global.window?.location.hostname || '';
let rtmpURL;
@@ -79,7 +79,13 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
Streaming Keys:
</Text>
<Text strong className="stream-info-box">
<Link href="/admin/config/server"> View </Link>
{!streamKeyOverridden ? (
<Link href="/admin/config/server"> View </Link>
) : (
<span style={{ paddingLeft: '10px', fontWeight: 'normal' }}>
Overridden via command line.
</span>
)}
</Text>
</div>
</div>

View File

@@ -159,8 +159,8 @@ export const VideoVariantForm: FC<VideoVariantFormProps> = ({
<Slider
tipFormatter={value => ENCODER_PRESET_TOOLTIPS[value]}
onChange={handleVideoCpuUsageLevelChange}
min={1}
max={Object.keys(ENCODER_PRESET_SLIDER_MARKS).length}
min={0}
max={Object.keys(ENCODER_PRESET_SLIDER_MARKS).length - 1}
marks={ENCODER_PRESET_SLIDER_MARKS}
defaultValue={dataState.cpuUsageLevel}
value={dataState.cpuUsageLevel}

View File

@@ -87,7 +87,7 @@ const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setE
setHasChanged(false);
}
};
// Default auto-generated key
const defaultKey = generateRndKey();

View File

@@ -14,23 +14,6 @@
}
}
.nameChangeView {
display: flex;
font-size: 0.9rem;
border-radius: var(--theme-rounded-corners);
padding: 5px 15px;
color: var(--theme-color-components-text-on-light);
background-color: var(--color-owncast-background);
& .nameChangeText {
font-weight: bold;
font-family: var(--theme-text-display-font-family);
& .plain {
font-weight: normal;
font-family: var(--theme-text-body-font-family) !important;
}
}
}
.chatContainer {
display: flex;
flex-direction: column;

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
import { Virtuoso } from 'react-virtuoso';
import { useState, useMemo, useRef, CSSProperties, FC, useEffect } from 'react';
import dynamic from 'next/dynamic';
import {
ConnectedClientInfoEvent,
FediverseEvent,
MessageType,
NameChangeEvent,
} from '../../../interfaces/socket-events';
@@ -11,17 +11,13 @@ import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatUserMessage } from '../ChatUserMessage/ChatUserMessage';
import { ChatTextField } from '../ChatTextField/ChatTextField';
import { ChatModeratorNotification } from '../ChatModeratorNotification/ChatModeratorNotification';
// import ChatActionMessage from '../ChatAction/ChatActionMessage';
import { ChatSystemMessage } from '../ChatSystemMessage/ChatSystemMessage';
import { ChatJoinMessage } from '../ChatJoinMessage/ChatJoinMessage';
import { ScrollToBotBtn } from './ScrollToBotBtn';
import { ChatActionMessage } from '../ChatActionMessage/ChatActionMessage';
import { ChatSocialMessage } from '../ChatSocialMessage/ChatSocialMessage';
import { ChatNameChangeMessage } from '../ChatNameChangeMessage/ChatNameChangeMessage';
// Lazy loaded components
const EditFilled = dynamic(() => import('@ant-design/icons/EditFilled'), {
ssr: false,
});
export type ChatContainerProps = {
messages: ChatMessage[];
usernameToHighlight: string;
@@ -31,7 +27,11 @@ export type ChatContainerProps = {
height?: string;
};
function shouldCollapseMessages(messages: ChatMessage[], index: number): boolean {
function shouldCollapseMessages(
messages: ChatMessage[],
index: number,
collapsedMessageIds: Set<string>,
): boolean {
if (messages.length < 2) {
return false;
}
@@ -53,14 +53,24 @@ function shouldCollapseMessages(messages: ChatMessage[], index: number): boolean
return false;
}
const maxTimestampDelta = 1000 * 60 * 2; // 2 minutes
const maxTimestampDelta = 1000 * 60; // 1 minute
const lastTimestamp = new Date(lastMessage?.timestamp).getTime();
const thisTimestamp = new Date(message.timestamp).getTime();
if (thisTimestamp - lastTimestamp > maxTimestampDelta) {
return false;
}
return id === lastMessage?.user.id;
if (id !== lastMessage?.user.id) {
return false;
}
// Limit the number of messages that can be collapsed in a row.
const maxCollapsedMessageCount = 5;
if (collapsedMessageIds.size >= maxCollapsedMessageCount) {
return false;
}
return true;
}
function checkIsModerator(message: ChatMessage | ConnectedClientInfoEvent) {
@@ -83,28 +93,33 @@ export const ChatContainer: FC<ChatContainerProps> = ({
showInput,
height,
}) => {
const [atBottom, setAtBottom] = useState(false);
const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(false);
const chatContainerRef = useRef(null);
const showScrollToBottomButtonDelay = useRef(null);
const scrollToBottomDelay = useRef(null);
const getNameChangeViewForMessage = (message: NameChangeEvent) => {
const { oldName, user } = message;
const { displayName, displayColor } = user;
const color = `var(--theme-color-users-${displayColor})`;
const collapsedMessageIds = new Set<string>();
return (
<div className={styles.nameChangeView}>
<div style={{ marginRight: 5, height: 'max-content', margin: 'auto 5px auto 0' }}>
<EditFilled />
</div>
<div className={styles.nameChangeText}>
<span style={{ color }}>{oldName}</span>
<span className={styles.plain}> is now known as </span>
<span style={{ color }}>{displayName}</span>
</div>
</div>
);
const setShowScrolltoBottomButtonWithDelay = (show: boolean) => {
showScrollToBottomButtonDelay.current = setTimeout(() => {
setShowScrollToBottomButton(show);
}, 1500);
};
useEffect(
() =>
// Clear the timer when the component unmounts
() => {
clearTimeout(showScrollToBottomButtonDelay.current);
clearTimeout(scrollToBottomDelay.current);
},
[],
);
const getFediverseMessage = (message: FediverseEvent) => <ChatSocialMessage message={message} />;
const getUserJoinedMessage = (message: ChatMessage) => {
const {
user: { displayName, displayColor },
@@ -123,6 +138,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
const { body } = message;
return <ChatActionMessage body={body} />;
};
const getConnectedInfoMessage = (message: ConnectedClientInfoEvent) => {
const modStatusUpdate = checkIsModerator(message);
if (!modStatusUpdate) {
@@ -136,28 +152,39 @@ export const ChatContainer: FC<ChatContainerProps> = ({
return <ChatModeratorNotification />;
};
const getUserChatMessageView = (index: number, message: ChatMessage) => {
const collapsed = shouldCollapseMessages(messages, index, collapsedMessageIds);
if (!collapsed) {
collapsedMessageIds.clear();
} else {
collapsedMessageIds.add(message.id);
}
return (
<ChatUserMessage
message={message}
showModeratorMenu={isModerator} // Moderators have access to an additional menu
highlightString={usernameToHighlight} // What to highlight in the message
sentBySelf={message.user?.id === chatUserId} // The local user sent this message
sameUserAsLast={collapsed}
isAuthorModerator={message.user?.scopes?.includes('MODERATOR')}
isAuthorBot={message.user?.scopes?.includes('BOT')}
isAuthorAuthenticated={message.user?.authenticated}
key={message.id}
/>
);
};
const getViewForMessage = (
index: number,
message: ChatMessage | NameChangeEvent | ConnectedClientInfoEvent,
message: ChatMessage | NameChangeEvent | ConnectedClientInfoEvent | FediverseEvent,
) => {
switch (message.type) {
case MessageType.CHAT:
return (
<ChatUserMessage
message={message as ChatMessage}
showModeratorMenu={isModerator} // Moderators have access to an additional menu
highlightString={usernameToHighlight} // What to highlight in the message
sentBySelf={message.user?.id === chatUserId} // The local user sent this message
sameUserAsLast={shouldCollapseMessages(messages, index)}
isAuthorModerator={(message as ChatMessage).user.scopes?.includes('MODERATOR')}
isAuthorAuthenticated={message.user?.authenticated}
key={message.id}
/>
);
return getUserChatMessageView(index, message as ChatMessage);
case MessageType.NAME_CHANGE:
return getNameChangeViewForMessage(message as NameChangeEvent);
return <ChatNameChangeMessage message={message as NameChangeEvent} />;
case MessageType.CONNECTED_USER_INFO:
return getConnectedInfoMessage(message);
return getConnectedInfoMessage(message as ConnectedClientInfoEvent);
case MessageType.USER_JOINED:
return getUserJoinedMessage(message as ChatMessage);
case MessageType.CHAT_ACTION:
@@ -170,22 +197,29 @@ export const ChatContainer: FC<ChatContainerProps> = ({
key={message.id}
/>
);
case MessageType.FEDIVERSE_ENGAGEMENT_FOLLOW:
return getFediverseMessage(message as FediverseEvent);
case MessageType.FEDIVERSE_ENGAGEMENT_LIKE:
return getFediverseMessage(message as FediverseEvent);
case MessageType.FEDIVERSE_ENGAGEMENT_REPOST:
return getFediverseMessage(message as FediverseEvent);
default:
return null;
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const scrollChatToBottom = (ref, behavior = 'smooth') => {
setTimeout(() => {
clearTimeout(scrollToBottomDelay.current);
clearTimeout(showScrollToBottomButtonDelay.current);
scrollToBottomDelay.current = setTimeout(() => {
ref.current?.scrollToIndex({
index: messages.length - 1,
behavior,
});
setIsAtBottom(true);
setShowScrollToBottomButton(false);
}, 100);
setAtBottom(true);
};
// This is a hack to force a scroll to the very bottom of the chat messages
@@ -194,6 +228,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
useEffect(() => {
setTimeout(() => {
scrollChatToBottom(chatContainerRef, 'auto');
setShowScrolltoBottomButtonWithDelay(false);
}, 500);
}, []);
@@ -207,22 +242,41 @@ export const ChatContainer: FC<ChatContainerProps> = ({
ref={chatContainerRef}
data={messages}
itemContent={(index, message) => getViewForMessage(index, message)}
followOutput={(isAtBottom: boolean) => {
initialTopMostItemIndex={messages.length - 1}
followOutput={() => {
clearTimeout(showScrollToBottomButtonDelay.current);
if (isAtBottom) {
scrollChatToBottom(chatContainerRef, 'smooth');
setShowScrollToBottomButton(false);
scrollChatToBottom(chatContainerRef, 'auto');
return 'smooth';
}
setShowScrolltoBottomButtonWithDelay(true);
return false;
}}
alignToBottom
atBottomThreshold={70}
atBottomStateChange={bottom => {
setAtBottom(bottom);
setIsAtBottom(bottom);
if (bottom) {
setShowScrollToBottomButton(false);
} else {
setShowScrolltoBottomButtonWithDelay(true);
}
}}
/>
{!atBottom && <ScrollToBotBtn chatContainerRef={chatContainerRef} messages={messages} />}
{showScrollToBottomButton && (
<ScrollToBotBtn
onClick={() => {
scrollChatToBottom(chatContainerRef, 'auto');
}}
/>
)}
</>
),
[messages, usernameToHighlight, chatUserId, isModerator, atBottom],
[messages, usernameToHighlight, chatUserId, isModerator, showScrollToBottomButton, isAtBottom],
);
return (

View File

@@ -1,7 +1,6 @@
import { Button } from 'antd';
import dynamic from 'next/dynamic';
import { FC, MutableRefObject } from 'react';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { FC } from 'react';
import styles from './ChatContainer.module.scss';
// Lazy loaded components
@@ -12,23 +11,18 @@ const VerticalAlignBottomOutlined = dynamic(
ssr: false,
},
);
type Props = {
chatContainerRef: MutableRefObject<any>;
messages: ChatMessage[];
onClick: () => void;
};
export const ScrollToBotBtn: FC<Props> = ({ chatContainerRef, messages }) => (
export const ScrollToBotBtn: FC<Props> = ({ onClick }) => (
<div className={styles.toBottomWrap}>
<Button
type="default"
style={{ color: 'currentColor' }}
icon={<VerticalAlignBottomOutlined />}
onClick={() =>
chatContainerRef.current.scrollToIndex({
index: messages.length - 1,
behavior: 'auto',
})
}
onClick={onClick}
>
Go to last message
</Button>

View File

@@ -0,0 +1,15 @@
.nameChangeView {
display: flex;
font-size: 0.9rem;
border-radius: var(--theme-rounded-corners);
padding: 5px 15px;
color: var(--theme-color-components-chat-text);
& .nameChangeText {
font-weight: bold;
font-family: var(--theme-text-display-font-family);
& .plain {
font-weight: normal;
font-family: var(--theme-text-body-font-family) !important;
}
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ChatNameChangeMessage } from './ChatNameChangeMessage';
export default {
title: 'owncast/Chat/Messages/Chat name change',
component: ChatNameChangeMessage,
} as ComponentMeta<typeof ChatNameChangeMessage>;
const Template: ComponentStory<typeof ChatNameChangeMessage> = args => (
<ChatNameChangeMessage {...args} />
);
export const Basic = Template.bind({});
Basic.args = {
message: {
oldName: 'JohnnyOldName',
user: {
displayName: 'JohnnyNewName',
displayColor: '3',
},
},
};

View File

@@ -0,0 +1,35 @@
// export const ChatSocialMessage: FC<ChatSocialMessageProps> = ({ message }) => {
import dynamic from 'next/dynamic';
import { FC } from 'react';
import { NameChangeEvent } from '../../../interfaces/socket-events';
import styles from './ChatNameChangeMessage.module.scss';
export interface ChatNameChangeMessageProps {
message: NameChangeEvent;
}
// Lazy loaded components
const EditFilled = dynamic(() => import('@ant-design/icons/EditFilled'), {
ssr: false,
});
export const ChatNameChangeMessage: FC<ChatNameChangeMessageProps> = ({ message }) => {
const { oldName, user } = message;
const { displayName, displayColor } = user;
const color = `var(--theme-color-users-${displayColor})`;
return (
<div className={styles.nameChangeView}>
<div style={{ marginRight: 5, height: 'max-content', margin: 'auto 5px auto 0' }}>
<EditFilled />
</div>
<div className={styles.nameChangeText}>
<span style={{ color }}>{oldName}</span>
<span className={styles.plain}> is now known as </span>
<span style={{ color }}>{displayName}</span>
</div>
</div>
);
};

View File

@@ -4,9 +4,7 @@
border-style: solid;
padding: 10px 10px;
border-radius: 15px;
height: 85px;
width: 300px;
overflow: hidden;
background-color: var(--theme-color-background-main);
&:hover {
border-color: var(--theme-text-link);
@@ -18,11 +16,22 @@
border-color: rgba(0, 0, 0, 0.3);
border-width: 1px;
border-style: solid;
font-size: 1.8rem;
}
.avatarColumn {
max-width: 75px;
min-width: 75px;
}
.body {
color: var(--theme-color-components-text-on-light);
text-overflow: ellipsis;
line-height: 1.2rem;
p {
margin: 0;
}
}
.account {
@@ -32,21 +41,16 @@
}
.icon {
position: relative;
position: absolute;
width: 25px;
height: 25px;
top: -20px;
top: 40px;
left: 40px;
border-color: white;
border-width: 1px;
border-color: var(--theme-color-background-main);
border-width: 2px;
border-style: solid;
border-radius: 50%;
background-size: cover;
background-position: center;
}
.placeholder {
width: 100%;
height: 100%;
}
}

View File

@@ -13,8 +13,8 @@ const Template: ComponentStory<typeof ChatSocialMessage> = args => <ChatSocialMe
export const Follow = Template.bind({});
Follow.args = {
message: {
type: 'follow',
body: 'james followed this live stream.',
type: 'FEDIVERSE_ENGAGEMENT_FOLLOW',
body: '<p>james followed this live stream.</p>',
title: 'james@mastodon.social',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
@@ -24,8 +24,8 @@ Follow.args = {
export const Like = Template.bind({});
Like.args = {
message: {
type: 'like',
body: 'james liked that this stream went live.',
type: 'FEDIVERSE_ENGAGEMENT_LIKE',
body: '<p>james liked that this stream went live.</p>',
title: 'james@mastodon.social',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
@@ -35,10 +35,32 @@ Like.args = {
export const Repost = Template.bind({});
Repost.args = {
message: {
type: 'repost',
body: 'james shared this stream with their followers.',
type: 'FEDIVERSE_ENGAGEMENT_REPOST',
body: '<p>james shared this stream with their followers.</p>',
title: 'james@mastodon.social',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
},
};
export const LongAccountName = Template.bind({});
LongAccountName.args = {
message: {
type: 'FEDIVERSE_ENGAGEMENT_REPOST',
body: '<p>james shared this stream with their followers.</p>',
title: 'littlejimmywilliams@technology.biz.net.org.technology.gov',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
},
};
export const InvalidAvatarImage = Template.bind({});
InvalidAvatarImage.args = {
message: {
type: 'FEDIVERSE_ENGAGEMENT_REPOST',
body: '<p>james shared this stream with their followers.</p>',
title: 'james@mastodon.social',
image: 'https://xx.xx/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
},
};

View File

@@ -25,13 +25,13 @@ export const ChatSocialMessage: FC<ChatSocialMessageProps> = ({ message }) => {
let Icon;
switch (type.toString()) {
case 'follow':
case 'FEDIVERSE_ENGAGEMENT_FOLLOW':
Icon = FollowIcon;
break;
case 'like':
case 'FEDIVERSE_ENGAGEMENT_LIKE':
Icon = LikeIcon;
break;
case 'repost':
case 'FEDIVERSE_ENGAGEMENT_REPOST':
Icon = RepostIcon;
break;
default:
@@ -42,15 +42,16 @@ export const ChatSocialMessage: FC<ChatSocialMessageProps> = ({ message }) => {
<div className={cn([styles.follower, 'chat-message_social'])}>
<a href={link} target="_blank" rel="noreferrer">
<Row wrap={false}>
<Col span={6}>
<Avatar src={image} alt="Avatar" className={styles.avatar}>
<img src="/logo" alt="Logo" className={styles.placeholder} />
<Col span={6} className={styles.avatarColumn}>
<Avatar src={image} alt="Avatar" className={styles.avatar} size="large">
{title.charAt(0).toUpperCase()}
</Avatar>
<Icon className={styles.icon} />
</Col>
<Col>
<Row className={styles.account}>{title}</Row>
<Row className={styles.body}>{body}</Row>
<Row className={styles.body} dangerouslySetInnerHTML={{ __html: body }} />
</Col>
</Row>
</a>

View File

@@ -30,9 +30,11 @@
}
mark {
padding-left: 0.35em;
padding-right: 0.35em;
background-color: var(--theme-color-palette-12);
padding-left: 0.3em;
padding-right: 0.3em;
color: var(--theme-color-palette-4);
border-radius: var(--theme-rounded-corners);
background-color: var(--color-owncast-palette-7);
}
}

View File

@@ -1,9 +1,10 @@
/* eslint-disable react/no-danger */
import { Highlight } from 'react-highlighter-ts';
import { FC } from 'react';
import cn from 'classnames';
import { Interweave } from 'interweave';
import { UrlMatcher } from 'interweave-autolink';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import styles from './ChatSystemMessage.module.scss';
import { ChatMessageHighlightMatcher } from '../ChatUserMessage/customMatcher';
export type ChatSystemMessageProps = {
message: ChatMessage;
@@ -21,8 +22,13 @@ export const ChatSystemMessage: FC<ChatSystemMessageProps> = ({
<div className={styles.user}>
<span className={styles.userName}>{displayName}</span>
</div>
<Highlight search={highlightString}>
<div className={styles.message} dangerouslySetInnerHTML={{ __html: body }} />
</Highlight>
<Interweave
className={styles.message}
content={body}
matchers={[
new UrlMatcher('url', { validateTLD: false }),
new ChatMessageHighlightMatcher('highlight', { highlightString }),
]}
/>
</div>
);

View File

@@ -33,7 +33,7 @@ export default {
component: ChatTextField,
parameters: {
fetchMock: mocks,
chromatic: { diffThreshold: 0.8 },
chromatic: { diffThreshold: 0.88 },
design: {
type: 'image',

View File

@@ -1,7 +1,7 @@
import { Popover } from 'antd';
import React, { FC, useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor, Node, Path } from 'slate';
import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor } from 'slate';
import { Slate, Editable, withReact, ReactEditor, useSelected, useFocused } from 'slate-react';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
@@ -169,31 +169,10 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText }) => {
const insertImage = (url, name) => {
if (!url) return;
const { selection } = editor;
const image = createImageNode(name, url, name);
Transforms.insertNodes(editor, image, { select: true });
if (selection) {
const [parentNode, parentPath] = Editor.parent(editor, selection.focus?.path);
if (editor.isVoid(parentNode) || Node.string(parentNode).length) {
// Insert the new image node after the void node or a node with content
Transforms.insertNodes(editor, image, {
at: Path.next(parentPath),
select: true,
});
} else {
// If the node is empty, replace it instead
// Transforms.removeNodes(editor, { at: parentPath });
Transforms.insertNodes(editor, image, { at: parentPath, select: true });
Editor.normalize(editor, { force: true });
}
} else {
// Insert the new image node at the bottom of the Editor when selection
// is falsey
Transforms.insertNodes(editor, image, { select: true });
}
Transforms.insertNodes(editor, image);
Editor.normalize(editor, { force: true });
};
// Native emoji

View File

@@ -0,0 +1,17 @@
import dynamic from 'next/dynamic';
import React, { FC } from 'react';
import { ChatUserBadge } from './ChatUserBadge';
// Lazy loaded components
const BulbFilled = dynamic(() => import('@ant-design/icons/BulbFilled'), {
ssr: false,
});
export type BotBadgeProps = {
userColor: number;
};
export const BotUserBadge: FC<BotBadgeProps> = ({ userColor }) => (
<ChatUserBadge badge={<BulbFilled />} userColor={userColor} title="Bot" />
);

View File

@@ -3,6 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ChatUserBadge } from './ChatUserBadge';
import { ModerationBadge } from './ModerationBadge';
import { AuthedUserBadge } from './AuthedUserBadge';
import { BotUserBadge } from './BotUserBadge';
export default {
title: 'owncast/Chat/Messages/User Flag',
@@ -24,6 +25,8 @@ const AuthedTemplate: ComponentStory<typeof ModerationBadge> = args => (
<AuthedUserBadge {...args} />
);
const BotTemplate: ComponentStory<typeof BotUserBadge> = args => <BotUserBadge {...args} />;
export const Authenticated = AuthedTemplate.bind({});
Authenticated.args = {
userColor: '3',
@@ -34,6 +37,11 @@ Moderator.args = {
userColor: '5',
};
export const Bot = BotTemplate.bind({});
Bot.args = {
userColor: '7',
};
export const Generic = Template.bind({});
Generic.args = {
badge: '?',

View File

@@ -56,7 +56,7 @@ const messageWithLinkAndCustomEmoji: ChatMessage = JSON.parse(`{
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": []
},
"body": "Test message with a link https://owncast.online and a custom emoji <img src='/img/emoji/blob/ablobattention.gif' width='30px'/> ."}`);
"body": "Test message with a link https://owncast.online and a custom emoji <img src='/img/emoji/mutant/skull.svg' width='30px'/> ."}`);
const moderatorMessage: ChatMessage = JSON.parse(`{
"type": "CHAT",
@@ -89,6 +89,22 @@ const authenticatedUserMessage: ChatMessage = JSON.parse(`{
},
"body": "I am an authenticated user."}`);
const botUserMessage: ChatMessage = JSON.parse(`{
"type": "CHAT",
"id": "wY-MEXwnR",
"timestamp": "2022-04-28T20:30:27.001762726Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 7,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": ["gifted-nobel", "EliteMooseTaskForce"],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"authenticated": true,
"scopes": ["bot"]
},
"body": "I am a bot."}`);
export const WithoutModeratorMenu = Template.bind({});
WithoutModeratorMenu.args = {
message: standardMessage,
@@ -121,6 +137,13 @@ FromAuthenticatedUser.args = {
isAuthorAuthenticated: true,
};
export const FromBotUser = Template.bind({});
FromBotUser.args = {
message: botUserMessage,
showModeratorMenu: false,
isAuthorBot: true,
};
export const WithStringHighlighted = Template.bind({});
WithStringHighlighted.args = {
message: standardMessage,

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-danger */
import { FC, ReactNode } from 'react';
import cn from 'classnames';
import { Tooltip } from 'antd';
@@ -14,6 +13,7 @@ import { accessTokenAtom } from '../../stores/ClientConfigStore';
import { User } from '../../../interfaces/user.model';
import { AuthedUserBadge } from '../ChatUserBadge/AuthedUserBadge';
import { ModerationBadge } from '../ChatUserBadge/ModerationBadge';
import { BotUserBadge } from '../ChatUserBadge/BotUserBadge';
// Lazy loaded components
@@ -35,6 +35,7 @@ export type ChatUserMessageProps = {
sameUserAsLast: boolean;
isAuthorModerator: boolean;
isAuthorAuthenticated: boolean;
isAuthorBot: boolean;
};
export type UserTooltipProps = {
@@ -61,6 +62,7 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
sameUserAsLast,
isAuthorModerator,
isAuthorAuthenticated,
isAuthorBot,
}) => {
const { id: messageId, body, user, timestamp } = message;
const { id: userId, displayName, displayColor } = user;
@@ -76,7 +78,9 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
if (isAuthorAuthenticated) {
badgeNodes.push(<AuthedUserBadge key="auth" userColor={displayColor} />);
}
if (isAuthorBot) {
badgeNodes.push(<BotUserBadge key="bot" userColor={displayColor} />);
}
return (
<div
className={cn(

View File

@@ -40,6 +40,7 @@
font-size: 1.7rem;
font-weight: bold;
line-height: 30px;
margin: unset;
}
.subtitle {
@@ -48,6 +49,7 @@
line-height: 1.3;
color: var(--theme-color-background-header);
max-width: 900px;
margin-top: 7px;
}
}

View File

@@ -8,21 +8,13 @@ import styles from './ContentHeader.module.scss';
export type ContentHeaderProps = {
name: string;
title: string;
summary: string;
tags: string[];
links: SocialLink[];
logo: string;
};
export const ContentHeader: FC<ContentHeaderProps> = ({
name,
title,
summary,
logo,
tags,
links,
}) => (
export const ContentHeader: FC<ContentHeaderProps> = ({ name, summary, logo, tags, links }) => (
<div className={styles.root}>
<div className={styles.logoTitleSection}>
<div className={styles.logo}>
@@ -31,7 +23,7 @@ export const ContentHeader: FC<ContentHeaderProps> = ({
<div className={styles.titleSection}>
<h2 className={cn(styles.title, styles.row, 'header-title')}>{name}</h2>
<h3 className={cn(styles.subtitle, styles.row, 'header-subtitle')}>
<Linkify>{title || summary}</Linkify>
<Linkify>{summary}</Linkify>
</h3>
<div className={cn(styles.tagList, styles.row)}>
{tags.length > 0 && tags.map(tag => <span key={tag}>#{tag}&nbsp;</span>)}

View File

@@ -1,4 +0,0 @@
/* eslint-disable react/no-danger */
export const HtmlComment = ({ text }) => (
<span style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `\n\n<!-- ${text} -->` }} />
);

View File

@@ -0,0 +1,158 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { MutableSnapshot, RecoilRoot } from 'recoil';
import { makeEmptyClientConfig } from '../../../interfaces/client-config.model';
import { ServerStatus, makeEmptyServerStatus } from '../../../interfaces/server-status.model';
import {
accessTokenAtom,
appStateAtom,
chatMessagesAtom,
chatVisibleToggleAtom,
clientConfigStateAtom,
currentUserAtom,
fatalErrorStateAtom,
isMobileAtom,
isVideoPlayingAtom,
serverStatusState,
} from '../../stores/ClientConfigStore';
import { Main } from './Main';
import { ClientConfigServiceContext } from '../../../services/client-config-service';
import { ChatServiceContext } from '../../../services/chat-service';
import {
ServerStatusServiceContext,
ServerStatusStaticService,
} from '../../../services/status-service';
import { clientConfigServiceMockOf } from '../../../services/client-config-service.mock';
import chatServiceMockOf from '../../../services/chat-service.mock';
import serverStatusServiceMockOf from '../../../services/status-service.mock';
import { VideoSettingsServiceContext } from '../../../services/video-settings-service';
import videoSettingsServiceMockOf from '../../../services/video-settings-service.mock';
import { grootUser, spidermanUser } from '../../../interfaces/user.fixture';
import { exampleChatHistory } from '../../../interfaces/chat-message.fixture';
export default {
title: 'owncast/Layout/Main',
parameters: {
layout: 'fullscreen',
},
} satisfies ComponentMeta<typeof Main>;
type StateInitializer = (mutableState: MutableSnapshot) => void;
const composeStateInitializers =
(...fns: Array<StateInitializer>): StateInitializer =>
state =>
fns.forEach(fn => fn?.(state));
const defaultClientConfig = {
...makeEmptyClientConfig(),
logo: 'http://localhost:8080/logo',
name: "Spiderman's super serious stream",
summary: 'Strong Spidey stops supervillains! Streamed Saturdays & Sundays.',
extraPageContent: 'Spiderman is **cool**',
};
const defaultServerStatus = makeEmptyServerStatus();
const onlineServerStatus: ServerStatus = {
...defaultServerStatus,
online: true,
viewerCount: 5,
};
const initializeDefaultState = (mutableState: MutableSnapshot) => {
mutableState.set(appStateAtom, {
videoAvailable: false,
chatAvailable: false,
});
mutableState.set(clientConfigStateAtom, defaultClientConfig);
mutableState.set(chatVisibleToggleAtom, true);
mutableState.set(accessTokenAtom, 'token');
mutableState.set(currentUserAtom, {
...spidermanUser,
isModerator: false,
});
mutableState.set(serverStatusState, defaultServerStatus);
mutableState.set(isMobileAtom, false);
mutableState.set(chatMessagesAtom, exampleChatHistory);
mutableState.set(isVideoPlayingAtom, false);
mutableState.set(fatalErrorStateAtom, null);
};
const ClientConfigServiceMock = clientConfigServiceMockOf(defaultClientConfig);
const ChatServiceMock = chatServiceMockOf(exampleChatHistory, {
...grootUser,
accessToken: 'some fake token',
});
const DefaultServerStatusServiceMock = serverStatusServiceMockOf(defaultServerStatus);
const OnlineServerStatusServiceMock = serverStatusServiceMockOf(onlineServerStatus);
const VideoSettingsServiceMock = videoSettingsServiceMockOf([]);
const Template: ComponentStory<typeof Main> = ({
initializeState,
ServerStatusServiceMock = DefaultServerStatusServiceMock,
...args
}: {
initializeState: (mutableState: MutableSnapshot) => void;
ServerStatusServiceMock: ServerStatusStaticService;
}) => (
<RecoilRoot initializeState={composeStateInitializers(initializeDefaultState, initializeState)}>
<ClientConfigServiceContext.Provider value={ClientConfigServiceMock}>
<ChatServiceContext.Provider value={ChatServiceMock}>
<ServerStatusServiceContext.Provider value={ServerStatusServiceMock}>
<VideoSettingsServiceContext.Provider value={VideoSettingsServiceMock}>
<Main {...args} />
</VideoSettingsServiceContext.Provider>
</ServerStatusServiceContext.Provider>
</ChatServiceContext.Provider>
</ClientConfigServiceContext.Provider>
</RecoilRoot>
);
export const OfflineDesktop: typeof Template = Template.bind({});
export const OfflineMobile: typeof Template = Template.bind({});
OfflineMobile.args = {
initializeState: (mutableState: MutableSnapshot) => {
mutableState.set(isMobileAtom, true);
},
};
OfflineMobile.parameters = {
viewport: {
defaultViewport: 'mobile1',
},
};
export const OfflineTablet: typeof Template = Template.bind({});
OfflineTablet.parameters = {
viewport: {
defaultViewport: 'tablet',
},
};
export const Online: typeof Template = Template.bind({});
Online.args = {
ServerStatusServiceMock: OnlineServerStatusServiceMock,
};
export const OnlineMobile: typeof Template = Online.bind({});
OnlineMobile.args = {
ServerStatusServiceMock: OnlineServerStatusServiceMock,
initializeState: (mutableState: MutableSnapshot) => {
mutableState.set(isMobileAtom, true);
},
};
OnlineMobile.parameters = {
viewport: {
defaultViewport: 'mobile1',
},
};
export const OnlineTablet: typeof Template = Online.bind({});
OnlineTablet.args = {
ServerStatusServiceMock: OnlineServerStatusServiceMock,
};
OnlineTablet.parameters = {
viewport: {
defaultViewport: 'tablet',
},
};

View File

@@ -12,6 +12,7 @@ import {
clientConfigStateAtom,
fatalErrorStateAtom,
appStateAtom,
serverStatusState,
} from '../../stores/ClientConfigStore';
import { Content } from '../../ui/Content/Content';
import { Header } from '../../ui/Header/Header';
@@ -25,6 +26,7 @@ import styles from './Main.module.scss';
import { PushNotificationServiceWorker } from '../../workers/PushNotificationServiceWorker/PushNotificationServiceWorker';
import { AppStateOptions } from '../../stores/application-state';
import { Noscript } from '../../ui/Noscript/Noscript';
import { ServerStatus } from '../../../interfaces/server-status.model';
const lockBodyStyle = `
body {
@@ -46,7 +48,8 @@ const FatalErrorStateModal = dynamic(
export const Main: FC = () => {
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const { name, title, customStyles } = clientConfig;
const clientStatus = useRecoilValue<ServerStatus>(serverStatusState);
const { name, customStyles } = clientConfig;
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const fatalError = useRecoilValue<DisplayableError>(fatalErrorStateAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
@@ -54,12 +57,14 @@ export const Main: FC = () => {
const layoutRef = useRef<HTMLDivElement>(null);
const { chatDisabled } = clientConfig;
const { videoAvailable } = appState;
const { online, streamTitle } = clientStatus;
useEffect(() => {
setupNoLinkReferrer(layoutRef.current);
}, []);
const isProduction = process.env.NODE_ENV === 'production';
const headerText = online ? streamTitle || name : name;
return (
<>
@@ -143,7 +148,7 @@ export const Main: FC = () => {
<Layout ref={layoutRef} className={styles.layout}>
<Header
name={title || name}
name={headerText}
chatAvailable={isChatAvailable}
chatDisabled={chatDisabled}
online={videoAvailable}

View File

@@ -1,9 +1,9 @@
import { FC, useEffect, useState } from 'react';
import { FC, useContext, useEffect, useState } from 'react';
import { atom, selector, useRecoilState, useSetRecoilState, RecoilEnv } from 'recoil';
import { useMachine } from '@xstate/react';
import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model';
import ClientConfigService from '../../services/client-config-service';
import ChatService from '../../services/chat-service';
import { ClientConfigServiceContext } from '../../services/client-config-service';
import { ChatServiceContext } from '../../services/chat-service';
import WebsocketService from '../../services/websocket-service';
import { ChatMessage } from '../../interfaces/chat-message.model';
import { CurrentUser } from '../../interfaces/current-user';
@@ -20,10 +20,11 @@ import {
ChatEvent,
MessageVisibilityEvent,
SocketEvent,
FediverseEvent,
} from '../../interfaces/socket-events';
import { mergeMeta } from '../../utils/helpers';
import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler';
import ServerStatusService from '../../services/status-service';
import { ServerStatusServiceContext } from '../../services/status-service';
import handleNameChangeEvent from './eventhandlers/handleNameChangeEvent';
import { DisplayableError } from '../../types/displayable-error';
@@ -154,13 +155,17 @@ export const visibleChatMessagesSelector = selector<ChatMessage[]>({
});
export const ClientConfigStore: FC = () => {
const ClientConfigService = useContext(ClientConfigServiceContext);
const ChatService = useContext(ChatServiceContext);
const ServerStatusService = useContext(ServerStatusServiceContext);
const [appState, appStateSend, appStateService] = useMachine(appStateModel);
const [currentUser, setCurrentUser] = useRecoilState(currentUserAtom);
const setChatAuthenticated = useSetRecoilState<boolean>(chatAuthenticatedAtom);
const [clientConfig, setClientConfig] = useRecoilState<ClientConfig>(clientConfigStateAtom);
const [, setServerStatus] = useRecoilState<ServerStatus>(serverStatusState);
const setClockSkew = useSetRecoilState<Number>(clockSkewAtom);
const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom);
const [chatMessages, setChatMessages] = useRecoilState<SocketEvent[]>(chatMessagesAtom);
const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom);
const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom);
@@ -208,7 +213,7 @@ export const ClientConfigStore: FC = () => {
setHasLoadedConfig(true);
} catch (error) {
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
console.error(`ClientConfigService -> getConfig() ERROR: \n`, error);
}
};
@@ -227,7 +232,7 @@ export const ClientConfigStore: FC = () => {
} catch (error) {
sendEvent([AppStateEvent.Fail]);
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
console.error(`serverStatusState -> getStatus() ERROR: \n`, error);
}
};
@@ -307,6 +312,15 @@ export const ClientConfigStore: FC = () => {
case MessageType.CHAT_ACTION:
setChatMessages(currentState => [...currentState, message as ChatEvent]);
break;
case MessageType.FEDIVERSE_ENGAGEMENT_FOLLOW:
setChatMessages(currentState => [...currentState, message as FediverseEvent]);
break;
case MessageType.FEDIVERSE_ENGAGEMENT_LIKE:
setChatMessages(currentState => [...currentState, message as FediverseEvent]);
break;
case MessageType.FEDIVERSE_ENGAGEMENT_REPOST:
setChatMessages(currentState => [...currentState, message as FediverseEvent]);
break;
case MessageType.VISIBILITY_UPDATE:
handleMessageVisibilityChange(message as MessageVisibilityEvent);
break;

View File

@@ -3,19 +3,46 @@
.root {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 100%;
width: 100%;
background-color: var(--theme-color-background-main);
height: 100%;
min-height: 0;
@include screen(desktop) {
height: var(--content-height);
}
.mainSection {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: min-content // Skeleton when app is loading
minmax(30px, min-content) // player
min-content // status bar when live
min-content // mid section
minmax(250px, 1fr) // mobile content
;
grid-template-columns: 100%;
&.offline {
grid-template-rows: min-content // Skeleton when app is loading
min-content // offline banner
min-content // status bar when live
min-content // mid section
minmax(250px, 1fr) // mobile content
;
}
@include screen(tablet) {
grid-template-columns: 100vw;
}
@include screen(desktop) {
overflow-y: scroll;
grid-template-rows: unset;
&.offline {
grid-template-rows: unset;
}
}
}
@@ -27,10 +54,6 @@
display: none;
}
.topSection {
padding: 0;
background-color: var(--theme-color-components-video-background);
}
.lowerSection {
padding: 0em 2%;
margin-bottom: 2em;
@@ -38,12 +61,21 @@
.lowerSectionMobile {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
padding: 0.3em;
}
}
.topSectionElement {
background-color: var(--theme-color-components-video-background);
}
.statusBar {
flex-shrink: 0;
}
.leftCol {
display: flex;
flex-direction: column;
@@ -53,13 +85,6 @@
display: grid;
}
.main {
display: grid;
flex: 1;
height: 100%;
grid-template-rows: 1fr auto;
}
.replacementBar {
display: flex;
justify-content: space-between;
@@ -101,3 +126,10 @@
}
}
}
.bottomPageContentContainer {
background-color: var(--theme-color-components-content-background);
padding: calc(2 * var(--content-padding));
border-radius: var(--theme-rounded-corners);
width: 100%;
}

View File

@@ -2,6 +2,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { Skeleton } from 'antd';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import classnames from 'classnames';
import { LOCAL_STORAGE_KEYS, getLocalStorage, setLocalStorage } from '../../../utils/localStorage';
import isPushNotificationSupported from '../../../utils/browserPushNotifications';
@@ -17,7 +18,6 @@ import {
serverStatusState,
} from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model';
import { CustomPageContent } from '../CustomPageContent/CustomPageContent';
import styles from './Content.module.scss';
import { Sidebar } from '../Sidebar/Sidebar';
@@ -29,30 +29,21 @@ import { OfflineBanner } from '../OfflineBanner/OfflineBanner';
import { AppStateOptions } from '../../stores/application-state';
import { FollowButton } from '../../action-buttons/FollowButton';
import { NotifyButton } from '../../action-buttons/NotifyButton';
import { ContentHeader } from '../../common/ContentHeader/ContentHeader';
import { ServerStatus } from '../../../interfaces/server-status.model';
import { Statusbar } from '../Statusbar/Statusbar';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ExternalAction } from '../../../interfaces/external-action';
import { Modal } from '../Modal/Modal';
import { ActionButtonMenu } from '../../action-buttons/ActionButtonMenu/ActionButtonMenu';
import { DesktopContent } from './DesktopContent';
import { MobileContent } from './MobileContent';
// Lazy loaded components
const FollowerCollection = dynamic(
() =>
import('../followers/FollowerCollection/FollowerCollection').then(
mod => mod.FollowerCollection,
),
{
ssr: false,
},
);
const FollowModal = dynamic(
() => import('../../modals/FollowModal/FollowModal').then(mod => mod.FollowModal),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 8 }} />,
},
);
@@ -63,6 +54,7 @@ const BrowserNotifyModal = dynamic(
),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 6 }} />,
},
);
@@ -70,6 +62,7 @@ const NotifyReminderPopup = dynamic(
() => import('../NotifyReminderPopup/NotifyReminderPopup').then(mod => mod.NotifyReminderPopup),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 8 }} />,
},
);
@@ -81,142 +74,8 @@ const OwncastPlayer = dynamic(
},
);
const ChatContainer = dynamic(
() => import('../../chat/ChatContainer/ChatContainer').then(mod => mod.ChatContainer),
{
ssr: false,
},
);
const Tabs = dynamic(() => import('antd').then(mod => mod.Tabs), {
ssr: false,
});
const DesktopContent = ({
name,
streamTitle,
summary,
tags,
socialHandles,
extraPageContent,
setShowFollowModal,
supportFediverseFeatures,
}) => {
const aboutTabContent = <CustomPageContent content={extraPageContent} />;
const followersTabContent = (
<div>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
const items = [{ label: 'About', key: '2', children: aboutTabContent }];
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
}
return (
<>
<div className={styles.lowerHalf} id="skip-to-content">
<ContentHeader
name={name}
title={streamTitle}
summary={summary}
tags={tags}
links={socialHandles}
logo="/logo"
/>
</div>
<div className={styles.lowerSection}>
{items.length > 1 ? <Tabs defaultActiveKey="0" items={items} /> : aboutTabContent}
</div>
</>
);
};
const MobileContent = ({
name,
streamTitle,
summary,
tags,
socialHandles,
extraPageContent,
messages,
currentUser,
showChat,
actions,
setExternalActionToDisplay,
setShowNotifyPopup,
setShowFollowModal,
supportFediverseFeatures,
supportsBrowserNotifications,
}) => {
if (!currentUser) {
return <Skeleton loading active paragraph={{ rows: 7 }} />;
}
const { id, displayName } = currentUser;
const chatContent = showChat && (
<ChatContainer
messages={messages}
usernameToHighlight={displayName}
chatUserId={id}
isModerator={false}
/>
);
const aboutTabContent = (
<>
<ContentHeader
name={name}
title={streamTitle}
summary={summary}
tags={tags}
links={socialHandles}
logo="/logo"
/>
<CustomPageContent content={extraPageContent} />
</>
);
const followersTabContent = (
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
);
const items = [
showChat && { label: 'Chat', key: '0', children: chatContent },
{ label: 'About', key: '2', children: aboutTabContent },
{ label: 'Followers', key: '3', children: followersTabContent },
];
const replacementTabBar = (props, DefaultTabBar) => (
<div className={styles.replacementBar}>
<DefaultTabBar {...props} className={styles.defaultTabBar} />
<ActionButtonMenu
className={styles.actionButtonMenu}
showFollowItem={supportFediverseFeatures}
showNotifyItem={supportsBrowserNotifications}
actions={actions}
externalActionSelected={setExternalActionToDisplay}
notifyItemSelected={() => setShowNotifyPopup(true)}
followItemSelected={() => setShowFollowModal(true)}
/>
</div>
);
return (
<div className={styles.lowerSectionMobile}>
<Tabs
className={styles.tabs}
defaultActiveKey="0"
items={items}
renderTabBar={replacementTabBar}
/>
</div>
);
};
const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => {
const { title, description, url } = externalActionToDisplay;
const { title, description, url, html } = externalActionToDisplay;
return (
<Modal
title={description || title}
@@ -224,7 +83,19 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
open={!!externalActionToDisplay}
height="80vh"
handleCancel={() => setExternalActionToDisplay(null)}
/>
>
{html ? (
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
style={{
height: '100%',
width: '100%',
overflow: 'auto',
}}
/>
) : null}
</Modal>
);
};
@@ -268,7 +139,8 @@ export const Content: FC = () => {
const externalActionSelected = (action: ExternalAction) => {
const { openExternally, url } = action;
if (openExternally) {
// apply openExternally only if we don't have an HTML embed
if (openExternally && url) {
window.open(url, '_blank');
} else {
setExternalActionToDisplay(action);
@@ -277,7 +149,7 @@ export const Content: FC = () => {
const externalActionButtons = externalActions.map(action => (
<ActionButton
key={action.url}
key={action.url || action.html}
action={action}
externalActionSelected={externalActionSelected}
/>
@@ -331,105 +203,111 @@ export const Content: FC = () => {
return (
<>
<div className={styles.main}>
<div className={styles.root}>
<div className={styles.mainSection}>
<div className={styles.topSection}>
{appState.appLoading && <Skeleton loading active paragraph={{ rows: 7 }} />}
{online && (
<OwncastPlayer
source="/hls/stream.m3u8"
online={online}
title={streamTitle || name}
/>
)}
{!online && !appState.appLoading && (
<div id="offline-message">
<OfflineBanner
showsHeader={false}
streamName={name}
customText={offlineMessage}
notificationsEnabled={browserNotificationsEnabled}
fediverseAccount={fediverseAccount}
lastLive={lastDisconnectTime}
onNotifyClick={() => setShowNotifyModal(true)}
onFollowClick={() => setShowFollowModal(true)}
/>
</div>
)}
{isStreamLive && (
<Statusbar
online={online}
lastConnectTime={lastConnectTime}
lastDisconnectTime={lastDisconnectTime}
viewerCount={viewerCount}
/>
)}
<div className={styles.root}>
<div className={classnames(styles.mainSection, { [styles.offline]: !online })}>
{appState.appLoading ? (
<Skeleton loading active paragraph={{ rows: 7 }} className={styles.topSectionElement} />
) : (
<div className="skeleton-placeholder" />
)}
{online && (
<OwncastPlayer
source="/hls/stream.m3u8"
online={online}
title={streamTitle || name}
className={styles.topSectionElement}
/>
)}
{!online && !appState.appLoading && (
<div id="offline-message">
<OfflineBanner
showsHeader={false}
streamName={name}
customText={offlineMessage}
notificationsEnabled={supportsBrowserNotifications}
fediverseAccount={fediverseAccount}
lastLive={lastDisconnectTime}
onNotifyClick={() => setShowNotifyModal(true)}
onFollowClick={() => setShowFollowModal(true)}
className={styles.topSectionElement}
/>
</div>
<div className={styles.midSection}>
<div className={styles.buttonsLogoTitleSection}>
{!isMobile && (
<ActionButtonRow>
{externalActionButtons}
{supportFediverseFeatures && (
<FollowButton size="small" onClick={() => setShowFollowModal(true)} />
)}
{supportsBrowserNotifications && (
<NotifyReminderPopup
open={showNotifyReminder}
notificationClicked={() => setShowNotifyModal(true)}
notificationClosed={() => disableNotifyReminderPopup()}
>
<NotifyButton onClick={() => setShowNotifyModal(true)} />
</NotifyReminderPopup>
)}
</ActionButtonRow>
)}
)}
{isStreamLive ? (
<Statusbar
online={online}
lastConnectTime={lastConnectTime}
lastDisconnectTime={lastDisconnectTime}
viewerCount={viewerCount}
className={classnames(styles.topSectionElement, styles.statusBar)}
/>
) : (
<div className="statusbar-placeholder" />
)}
<div className={styles.midSection}>
<div className={styles.buttonsLogoTitleSection}>
{!isMobile && (
<ActionButtonRow>
{externalActionButtons}
{supportFediverseFeatures && (
<FollowButton size="small" onClick={() => setShowFollowModal(true)} />
)}
{supportsBrowserNotifications && (
<NotifyReminderPopup
open={showNotifyReminder}
notificationClicked={() => setShowNotifyModal(true)}
notificationClosed={() => disableNotifyReminderPopup()}
>
<NotifyButton onClick={() => setShowNotifyModal(true)} />
</NotifyReminderPopup>
)}
</ActionButtonRow>
)}
<Modal
title="Browser Notifications"
open={showNotifyModal}
afterClose={() => disableNotifyReminderPopup()}
handleCancel={() => disableNotifyReminderPopup()}
>
<BrowserNotifyModal />
</Modal>
</div>
<Modal
title="Browser Notifications"
open={showNotifyModal}
afterClose={() => disableNotifyReminderPopup()}
handleCancel={() => disableNotifyReminderPopup()}
>
<BrowserNotifyModal />
</Modal>
</div>
{isMobile ? (
<MobileContent
name={name}
streamTitle={streamTitle}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
messages={messages}
currentUser={currentUser}
showChat={showChat}
actions={externalActions}
setExternalActionToDisplay={externalActionSelected}
setShowNotifyPopup={setShowNotifyModal}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
supportsBrowserNotifications={supportsBrowserNotifications}
/>
) : (
<DesktopContent
name={name}
streamTitle={streamTitle}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
/>
)}
{!isMobile && <Footer version={version} />}
</div>
{showChat && !isMobile && <Sidebar />}
{isMobile ? (
<MobileContent
name={name}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
messages={messages}
currentUser={currentUser}
showChat={showChat}
actions={externalActions}
setExternalActionToDisplay={externalActionSelected}
setShowNotifyPopup={setShowNotifyModal}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
supportsBrowserNotifications={supportsBrowserNotifications}
notifyItemSelected={() => setShowNotifyModal(true)}
followItemSelected={() => setShowFollowModal(true)}
externalActionSelected={externalActionSelected}
/>
) : (
<DesktopContent
name={name}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
/>
)}
{!isMobile && <Footer version={version} />}
</div>
{showChat && !isMobile && <Sidebar />}
</div>
{externalActionToDisplay && (
<ExternalModal

View File

@@ -0,0 +1,81 @@
import React, { ComponentType, FC } from 'react';
import dynamic from 'next/dynamic';
import { TabsProps } from 'antd';
import { SocialLink } from '../../../interfaces/social-link.model';
import styles from './Content.module.scss';
import { CustomPageContent } from '../CustomPageContent/CustomPageContent';
import { ContentHeader } from '../../common/ContentHeader/ContentHeader';
export type DesktopContentProps = {
name: string;
summary: string;
tags: string[];
socialHandles: SocialLink[];
extraPageContent: string;
setShowFollowModal: (show: boolean) => void;
supportFediverseFeatures: boolean;
};
// lazy loaded components
const Tabs: ComponentType<TabsProps> = dynamic(() => import('antd').then(mod => mod.Tabs), {
ssr: false,
});
const FollowerCollection = dynamic(
() =>
import('../followers/FollowerCollection/FollowerCollection').then(
mod => mod.FollowerCollection,
),
{
ssr: false,
},
);
export const DesktopContent: FC<DesktopContentProps> = ({
name,
summary,
tags,
socialHandles,
extraPageContent,
setShowFollowModal,
supportFediverseFeatures,
}) => {
const aboutTabContent = (
<div className={styles.bottomPageContentContainer}>
<CustomPageContent content={extraPageContent} />
</div>
);
const followersTabContent = (
<div className={styles.bottomPageContentContainer}>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
const items = [!!extraPageContent && { label: 'About', key: '2', children: aboutTabContent }];
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
}
return (
<>
<div className={styles.lowerHalf} id="skip-to-content">
<ContentHeader
name={name}
summary={summary}
tags={tags}
links={socialHandles}
logo="/logo"
/>
</div>
<div className={styles.lowerSection}>
{items.length > 1 ? (
<Tabs defaultActiveKey="0" items={items} />
) : (
!!extraPageContent && aboutTabContent
)}
</div>
</>
);
};

View File

@@ -0,0 +1,140 @@
import React, { ComponentType, FC } from 'react';
import dynamic from 'next/dynamic';
import { Skeleton, TabsProps } from 'antd';
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 { ActionButtonMenu } from '../../action-buttons/ActionButtonMenu/ActionButtonMenu';
import { ExternalAction } from '../../../interfaces/external-action';
export type MobileContentProps = {
name: string;
summary: string;
tags: string[];
socialHandles: SocialLink[];
extraPageContent: string;
notifyItemSelected: () => void;
followItemSelected: () => void;
setExternalActionToDisplay: (action: ExternalAction) => void;
setShowNotifyPopup: (show: boolean) => void;
setShowFollowModal: (show: boolean) => void;
supportFediverseFeatures: boolean;
messages: ChatMessage[];
currentUser: CurrentUser;
showChat: boolean;
actions: ExternalAction[];
externalActionSelected: (action: ExternalAction) => void;
supportsBrowserNotifications: boolean;
};
// lazy loaded components
const Tabs: ComponentType<TabsProps> = dynamic(() => import('antd').then(mod => mod.Tabs), {
ssr: false,
});
const FollowerCollection = dynamic(
() =>
import('../followers/FollowerCollection/FollowerCollection').then(
mod => mod.FollowerCollection,
),
{
ssr: false,
},
);
const ChatContainer = dynamic(
() => import('../../chat/ChatContainer/ChatContainer').then(mod => mod.ChatContainer),
{
ssr: false,
},
);
export const MobileContent: FC<MobileContentProps> = ({
name,
summary,
tags,
socialHandles,
extraPageContent,
messages,
currentUser,
showChat,
actions,
setExternalActionToDisplay,
setShowNotifyPopup,
setShowFollowModal,
supportFediverseFeatures,
supportsBrowserNotifications,
}) => {
if (!currentUser) {
return <Skeleton loading active paragraph={{ rows: 7 }} />;
}
const { id, displayName } = currentUser;
const chatContent = showChat && (
<ChatContainer
messages={messages}
usernameToHighlight={displayName}
chatUserId={id}
isModerator={false}
/>
);
const aboutTabContent = (
<>
<ContentHeader name={name} summary={summary} tags={tags} links={socialHandles} logo="/logo" />
{!!extraPageContent && (
<div className={styles.bottomPageContentContainer}>
<CustomPageContent content={extraPageContent} />
</div>
)}
</>
);
const followersTabContent = (
<div className={styles.bottomPageContentContainer}>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
const items = [];
if (showChat) {
items.push({ label: 'Chat', key: '0', children: chatContent });
}
items.push({ label: 'About', key: '2', children: aboutTabContent });
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
}
const replacementTabBar = (props, DefaultTabBar) => (
<div className={styles.replacementBar}>
<DefaultTabBar {...props} className={styles.defaultTabBar} />
<ActionButtonMenu
className={styles.actionButtonMenu}
showFollowItem={supportFediverseFeatures}
showNotifyItem={supportsBrowserNotifications}
actions={actions}
externalActionSelected={setExternalActionToDisplay}
notifyItemSelected={() => setShowNotifyPopup(true)}
followItemSelected={() => setShowFollowModal(true)}
/>
</div>
);
return (
<div className={styles.lowerSectionMobile}>
{items.length > 1 ? (
<Tabs
className={styles.tabs}
defaultActiveKey="0"
items={items}
renderTabBar={replacementTabBar}
/>
) : (
aboutTabContent
)}
</div>
);
};

View File

@@ -8,6 +8,7 @@ export type CrossfadeImageProps = {
height: string;
objectFit?: ObjectFit;
duration?: string;
className?: string;
};
const imgStyle: React.CSSProperties = {
@@ -22,6 +23,7 @@ export const CrossfadeImage: FC<CrossfadeImageProps> = ({
height,
objectFit = 'fill',
duration = '1s',
className,
}) => {
const spanStyle: React.CSSProperties = useMemo(
() => ({
@@ -52,7 +54,7 @@ export const CrossfadeImage: FC<CrossfadeImageProps> = ({
};
return (
<span style={spanStyle}>
<span style={spanStyle} className={className}>
{[...srcs, nextSrc].map(
(singleSrc, index) =>
singleSrc !== '' && (

View File

@@ -1,22 +1,14 @@
@import 'styles/mixins.scss';
.pageContentContainer {
@include flexCenter;
}
.customPageContent {
font-size: 1rem;
line-height: 1.6em;
color: var(--theme-color-components-text-on-light);
padding: calc(2 * var(--content-padding));
border-radius: var(--theme-rounded-corners);
width: 100%;
background-color: var(--theme-color-components-content-background);
hr {
margin: 1.35em 0;
border: 0;
border-top: solid 1px var(--theme-color-components-content-background);
border-top: solid 1px var(--theme-color-palette-6);
}
div.summary {

View File

@@ -18,12 +18,12 @@ const Template: ComponentStory<typeof CustomPageContent> = args => (
export const Example1 = Template.bind({});
Example1.args = {
content: `"\u003cp\u003eOwncast TV is a 24/7 live stream run by the Owncast project as an example of the software in use. Learn more about how you can have your own live stream that you completely control at \u003ca href=\"https://owncast.online\"\u003eowncast.online\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eThis example instance shows how you can customize the page by changing things like fonts and colors as well as how you can add custom action buttons such as a donation button.\u003c/p\u003e\n\u003cp\u003eStay tuned in to learn about Owncast, hear from some streamers about their experiences using it, some bits and pieces of Owncast promo material, and highlights from other projects that are pretty cool.\u003c/p\u003e\n\u003cp\u003eBut when you've seen what we have to share with you, do yourself a favor and visit the \u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e and find an awesome stream to check out!\u003c/p\u003e\n\u003chr\u003e\n\u003ch2\u003eLinks to content seen in this stream\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://owncast.online/quickstart/\"\u003eOwncast Install Quickstart\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://creativecommons.org\"\u003eCreative Commons\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://tilvids.com\"\u003eTILVids\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://studio.blender.org/\"\u003eBlender Studio\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://archive.org/details/computerchronicles\"\u003eComputer Chronicles\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://joinmastodon.org\"\u003eMastodon\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e",`,
content: `\u003cp\u003eOwncast TV is a 24/7 live stream run by the Owncast project as an example of the software in use. Learn more about how you can have your own live stream that you completely control at \u003ca href=\"https://owncast.online\"\u003eowncast.online\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eThis example instance shows how you can customize the page by changing things like fonts and colors as well as how you can add custom action buttons such as a donation button.\u003c/p\u003e\n\u003cp\u003eStay tuned in to learn about Owncast, hear from some streamers about their experiences using it, some bits and pieces of Owncast promo material, and highlights from other projects that are pretty cool.\u003c/p\u003e\n\u003cp\u003eBut when you've seen what we have to share with you, do yourself a favor and visit the \u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e and find an awesome stream to check out!\u003c/p\u003e\n\u003chr\u003e\n\u003ch2\u003eLinks to content seen in this stream\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://owncast.online/quickstart/\"\u003eOwncast Install Quickstart\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://creativecommons.org\"\u003eCreative Commons\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://tilvids.com\"\u003eTILVids\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://studio.blender.org/\"\u003eBlender Studio\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://archive.org/details/computerchronicles\"\u003eComputer Chronicles\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://joinmastodon.org\"\u003eMastodon\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e",`,
};
export const Example2 = Template.bind({});
Example2.args = {
content: `"<h1>WHAT IS HAPPENING HERE</h1>\n<p>Game That Tune Radio is live with fantastic video game music streaming around the clock! We've got music from NES, SNES, Sega Genesis, Nintendo 64, Playstation, PC, and more coming all the time! If it's been featured on our podcast, it's gonna be on this stream! We only play three songs from each game on our podcast, and we decided that everyone needs more tunes!</p>\n<p>We'll be updating this livestream with new games as they're played on the show, including your requests! To get priority in requesting games for the show, check out <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a></p>\n<p>Be sure to check out our live recordings of the Game That Tune podcast! We broadcast every Wednesday night at 9 PM EST on our YouTube channel as well as <a href=\"https://www.twitch.tv/GameThatTune\">https://www.twitch.tv/GameThatTune</a> and <a href=\"https://www.facebook.com/GameThatTune\">https://www.facebook.com/GameThatTune</a>\nTune in and join in on the fun! Find the podcast in iTunes every Wednesday morning or head to <a href=\"https://www.gamethattune.com\">https://www.gamethattune.com</a>!</p>\n<p>Visit <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a> to help us keep up this live stream and upgrade our equipment for the live show! We've got exclusive mixtapes for our patrons, and lots more stuff planned for the future, so consider helping us out!</p>\n<h1>HOW IT WORKS</h1>\n<p>Featuring music from over 1000 games! Check out <a href=\"https://music.gamethattune.com/songs\">https://music.gamethattune.com/songs</a> for the full list and make a request from your favorite game!</p>\n<p>Now that you've seen the list of games, make a request in the chat!</p>\n<p><code>!sr</code> + anything = general search<br>\n<code>!gr</code> + game title = random song from matching game<br>\n<code>!cr</code> + composer name = random song from matching composer<br>\n<code>!tr</code> + anything = random result only searching song titles<br>\n<code>!rr</code> + anything = random result from all searchable fields<br>\n<code>!game gtt</code> = starts a round of our guessing game for bonus points!</p>\n<p>We have gifs!</p>\n<p>Wanna see your favorite gif on screen? type <code>!summon</code> followed by the gif name! Want your favorite gif to take over the video? Type <code>!spawn</code> followed by the gif name!</p>\n<p>Still have questions? Ask the chatbot! type <code>!info</code> to...wait for it...get more info!</p>\n<p>Thanks for listening!</p>"`,
content: `<h1>WHAT IS HAPPENING HERE</h1>\n<p>Game That Tune Radio is live with fantastic video game music streaming around the clock! We've got music from NES, SNES, Sega Genesis, Nintendo 64, Playstation, PC, and more coming all the time! If it's been featured on our podcast, it's gonna be on this stream! We only play three songs from each game on our podcast, and we decided that everyone needs more tunes!</p>\n<p>We'll be updating this livestream with new games as they're played on the show, including your requests! To get priority in requesting games for the show, check out <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a></p>\n<p>Be sure to check out our live recordings of the Game That Tune podcast! We broadcast every Wednesday night at 9 PM EST on our YouTube channel as well as <a href=\"https://www.twitch.tv/GameThatTune\">https://www.twitch.tv/GameThatTune</a> and <a href=\"https://www.facebook.com/GameThatTune\">https://www.facebook.com/GameThatTune</a>\nTune in and join in on the fun! Find the podcast in iTunes every Wednesday morning or head to <a href=\"https://www.gamethattune.com\">https://www.gamethattune.com</a>!</p>\n<p>Visit <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a> to help us keep up this live stream and upgrade our equipment for the live show! We've got exclusive mixtapes for our patrons, and lots more stuff planned for the future, so consider helping us out!</p>\n<h1>HOW IT WORKS</h1>\n<p>Featuring music from over 1000 games! Check out <a href=\"https://music.gamethattune.com/songs\">https://music.gamethattune.com/songs</a> for the full list and make a request from your favorite game!</p>\n<p>Now that you've seen the list of games, make a request in the chat!</p>\n<p><code>!sr</code> + anything = general search<br>\n<code>!gr</code> + game title = random song from matching game<br>\n<code>!cr</code> + composer name = random song from matching composer<br>\n<code>!tr</code> + anything = random result only searching song titles<br>\n<code>!rr</code> + anything = random result from all searchable fields<br>\n<code>!game gtt</code> = starts a round of our guessing game for bonus points!</p>\n<p>We have gifs!</p>\n<p>Wanna see your favorite gif on screen? type <code>!summon</code> followed by the gif name! Want your favorite gif to take over the video? Type <code>!spawn</code> followed by the gif name!</p>\n<p>Still have questions? Ask the chatbot! type <code>!info</code> to...wait for it...get more info!</p>\n<p>Thanks for listening!</p>"`,
};
export const Example3 = Template.bind({});

View File

@@ -7,7 +7,7 @@ export type CustomPageContentProps = {
};
export const CustomPageContent: FC<CustomPageContentProps> = ({ content }) => (
<div className={styles.pageContentContainer} id="custom-page-content">
<div id="custom-page-content">
<div className={styles.customPageContent} dangerouslySetInnerHTML={{ __html: content }} />
</div>
);

View File

@@ -10,6 +10,11 @@
box-shadow: 0px 1px 3px 1px rgb(0 0 0 / 10%);
background-color: var(--theme-color-background-header);
h1 {
margin-top: unset;
margin-bottom: unset;
}
@include screen(mobile) {
--header-height: 3.85rem;
}
@@ -38,7 +43,9 @@
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
width: 70vw;
// 6rem is an overapproximation of the width of
// the user menu
max-width: min(70vw, calc(100vw - 6rem));
overflow: hidden;
line-height: 1.4;
}
@@ -57,3 +64,8 @@
width: auto;
height: auto;
}
.offlineTag {
cursor: default;
color: var(--theme-color-components-text-on-light);
}

View File

@@ -22,12 +22,7 @@ export type HeaderComponentProps = {
online: boolean;
};
export const Header: FC<HeaderComponentProps> = ({
name = 'Your stream title',
chatAvailable,
chatDisabled,
online,
}) => (
export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisabled, online }) => (
<header className={cn([`${styles.header}`], 'global-header')}>
{online ? (
<Link href="#player" className={styles.skipLink}>
@@ -55,7 +50,7 @@ export const Header: FC<HeaderComponentProps> = ({
{chatAvailable && !chatDisabled && <UserDropdown />}
{!chatAvailable && !chatDisabled && (
<Tooltip title="Chat is available when the stream is live." placement="left">
<Tag style={{ cursor: 'pointer' }}>Chat offline</Tag>
<Tag className={styles.offlineTag}>Chat offline</Tag>
</Tooltip>
)}
</header>

View File

@@ -11,3 +11,4 @@
background-color: var(--theme-color-components-modal-content-background);
color: var(--theme-color-components-modal-content-text);
}

View File

@@ -21,7 +21,7 @@
@include screen(tablet) {
font-size: 1.2rem;
padding: 1em;
margin: 1rem auto;
margin: 1rem 0.2rem;
}
}

View File

@@ -3,6 +3,7 @@ import { Divider } from 'antd';
import { FC } from 'react';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
import styles from './OfflineBanner.module.scss';
// Lazy loaded components
@@ -20,6 +21,7 @@ export type OfflineBannerProps = {
showsHeader?: boolean;
onNotifyClick?: () => void;
onFollowClick?: () => void;
className?: string;
};
export const OfflineBanner: FC<OfflineBannerProps> = ({
@@ -31,6 +33,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
showsHeader = true,
onNotifyClick,
onFollowClick,
className,
}) => {
let text;
if (customText) {
@@ -74,7 +77,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
}
return (
<div id="offline-banner" className={styles.outerContainer}>
<div id="offline-banner" className={classNames(styles.outerContainer, className)}>
<div className={styles.innerContainer}>
{showsHeader && (
<>

View File

@@ -2,6 +2,7 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import intervalToDuration from 'date-fns/intervalToDuration';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
import styles from './Statusbar.module.scss';
import { pluralize } from '../../../utils/helpers';
@@ -16,6 +17,7 @@ export type StatusbarProps = {
lastConnectTime?: Date;
lastDisconnectTime?: Date;
viewerCount: number;
className?: string;
};
function makeDurationString(lastConnectTime: Date): string {
@@ -43,6 +45,7 @@ export const Statusbar: FC<StatusbarProps> = ({
lastConnectTime,
lastDisconnectTime,
viewerCount,
className,
}) => {
const [, setNow] = useState(new Date());
@@ -75,7 +78,7 @@ export const Statusbar: FC<StatusbarProps> = ({
}
return (
<div className={styles.statusbar} role="status">
<div className={classNames(styles.statusbar, className)} role="status">
<div>{onlineMessage}</div>
<div>{rightSideMessage}</div>
</div>

View File

@@ -4,10 +4,6 @@
width: 100%;
padding: 5px;
@include screen(desktop) {
background-color: var(--theme-color-components-content-background);
}
@include screen(mobile) {
.followerRow {
justify-content: center;
@@ -16,8 +12,11 @@
}
.noFollowers {
padding: calc(2 * var(--content-padding));
padding: var(--content-padding);
border-radius: var(--theme-rounded-corners);
width: 100%;
background-color: var(--theme-color-components-content-background);
}
.pagination {
margin: 1rem;
}

View File

@@ -20,7 +20,7 @@ const mocks = {
response: {
status: 200,
body: {
total: 10,
total: 100,
results: [
{
link: 'https://sun.minuscule.space/users/mardijker',
@@ -175,14 +175,6 @@ const mocks = {
timestamp: '2022-03-30T14:41:32Z',
disabledAt: null,
},
{
link: 'https://gamethattune.club/users/mork',
name: 'mork',
username: 'mork@gamethattune.club',
image: '',
timestamp: '2022-03-30T14:37:10Z',
disabledAt: null,
},
{
link: 'https://fosstodon.org/users/owncast',
name: 'Owncast',
@@ -269,6 +261,9 @@ const noFollowersMock = {
export default {
title: 'owncast/Components/Followers/Followers collection',
component: FollowerCollection,
parameters: {
chromatic: { diffThreshold: 0.83 },
},
} as ComponentMeta<typeof FollowerCollection>;
const Template: ComponentStory<typeof FollowerCollection> = (args: object) => (

View File

@@ -19,17 +19,16 @@ export const FollowerCollection: FC<FollowerCollectionProps> = ({ name, onFollow
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const pages = Math.ceil(total / ITEMS_PER_PAGE);
const getFollowers = async () => {
try {
const response = await fetch(`${ENDPOINT}?page=${page}`);
const response = await fetch(`${ENDPOINT}?page=${page}&limit=${ITEMS_PER_PAGE}`);
const data = await response.json();
const { results, total: totalResults } = data;
setFollowers(results);
setTotal(totalResults);
setLoading(false);
} catch (error) {
console.error(error);
@@ -78,9 +77,12 @@ export const FollowerCollection: FC<FollowerCollectionProps> = ({ name, onFollow
</Row>
<Pagination
className={styles.pagination}
current={page}
pageSize={ITEMS_PER_PAGE}
total={pages || 1}
defaultPageSize={ITEMS_PER_PAGE}
total={total}
showSizeChanger={false}
onChange={p => {
setPage(p);
}}

View File

@@ -45,9 +45,4 @@
border-width: 1px;
border-style: solid;
}
.placeholder {
width: 100%;
height: 100%;
}
}

View File

@@ -13,13 +13,18 @@ export const SingleFollower: FC<SingleFollowerProps> = ({ follower }) => (
<a href={follower.link} target="_blank" rel="noreferrer">
<Row wrap={false}>
<Col span={6}>
<Avatar src={follower.image} alt="Avatar" className={styles.avatar}>
<img src="/logo" alt="Logo" className={styles.placeholder} />
</Avatar>
<Avatar
src={follower.image}
alt="Avatar"
className={styles.avatar}
icon={<img src="/logo" alt="Logo" />}
/>
</Col>
<Col span={18}>
<Row className={styles.name}>
<Typography.Text ellipsis>{follower.name}</Typography.Text>
<Typography.Text ellipsis>
{follower.name || follower.username.split('@', 2)[0]}
</Typography.Text>
</Row>
<Row className={styles.account}>
<Typography.Text ellipsis>{follower.username}</Typography.Text>

View File

@@ -8,7 +8,7 @@
aspect-ratio: 16 / 9;
@media (max-width: 1200px) {
height: unset;
height: 100%;
max-height: 75vh;
}

View File

@@ -1,7 +1,8 @@
import React, { FC, useEffect } from 'react';
import React, { FC, useContext, useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useHotkeys } from 'react-hotkeys-hook';
import { VideoJsPlayerOptions } from 'video.js';
import classNames from 'classnames';
import { VideoJS } from '../VideoJS/VideoJS';
import ViewerPing from '../viewer-ping';
import { VideoPoster } from '../VideoPoster/VideoPoster';
@@ -11,8 +12,8 @@ import PlaybackMetrics from '../metrics/playback';
import createVideoSettingsMenuButton from '../settings-menu';
import LatencyCompensator from '../latencyCompensator';
import styles from './OwncastPlayer.module.scss';
import { VideoSettingsServiceContext } from '../../../services/video-settings-service';
const VIDEO_CONFIG_URL = '/api/video/variants';
const PLAYER_VOLUME = 'owncast_volume';
const LATENCY_COMPENSATION_ENABLED = 'latencyCompensatorEnabled';
@@ -26,26 +27,17 @@ export type OwncastPlayerProps = {
online: boolean;
initiallyMuted?: boolean;
title: string;
className?: string;
};
async function getVideoSettings() {
let qualities = [];
try {
const response = await fetch(VIDEO_CONFIG_URL);
qualities = await response.json();
} catch (e) {
console.error(e);
}
return qualities;
}
export const OwncastPlayer: FC<OwncastPlayerProps> = ({
source,
online,
initiallyMuted = false,
title,
className,
}) => {
const VideoSettingsService = useContext(VideoSettingsServiceContext);
const playerRef = React.useRef(null);
const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom);
const clockSkew = useRecoilValue<Number>(clockSkewAtom);
@@ -148,7 +140,7 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
};
const createSettings = async (player, videojs) => {
const videoQualities = await getVideoSettings();
const videoQualities = await VideoSettingsService.getVideoQualities();
const menuButton = createVideoSettingsMenuButton(
player,
videojs,
@@ -308,7 +300,7 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
);
return (
<div className={styles.container} id="player">
<div className={classNames(styles.container, className)} id="player">
{online && (
<div className={styles.player}>
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} aria-label={title} />

View File

@@ -11,7 +11,18 @@
.vjs-big-play-button {
z-index: 10;
color: var(--theme-color-action);
font-size: 8rem !important;
// Setting the font size resizes the video.js
// BigPlayButton due to its style definitions
// (see https://github.com/videojs/video.js/blob/b306ce614e70e6d3305348d1b69e1434031d73ef/src/css/components/_big-play.scss)
// 30vmin determined by trial & error to not cause
// overflow with weird (small) x or y dimensions.
// min and max are also arbitrary; max was the old
// constant value. feel free to change if necessary,
// but check short and narrow screen sizes for overflow
// issues.
font-size: clamp(1rem, 30vmin, 8rem) !important;
border-color: transparent !important;
border-radius: var(--theme-rounded-corners) !important;
background-color: transparent !important;
@@ -58,10 +69,10 @@
font-family: VideoJS, serif;
font-weight: 400;
font-style: normal;
}
.vjs-icon-placeholder::before {
content: '\f110';
&::before {
content: '\f110';
}
}
}

View File

@@ -1,7 +1,10 @@
.poster {
background-color: black;
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.image {
background-color: black;
}

View File

@@ -36,6 +36,7 @@ export const VideoPoster: FC<VideoPosterProps> = ({ online, initialSrc, src: bas
objectFit="contain"
height="auto"
width="100%"
className={styles.image}
/>
)}
</div>