Merge branch 'develop' into fix/ImplementPasswordRules

This commit is contained in:
Jambaldorj Ochirpurev
2023-02-05 11:12:05 +01:00
committed by GitHub
871 changed files with 3900 additions and 5007 deletions

View File

@@ -1,15 +1,35 @@
import ChartJs from 'chart.js/auto';
import Chartkick from 'chartkick';
import format from 'date-fns/format';
import { LineChart } from 'react-chartkick';
import { FC } from 'react';
// from https://github.com/ankane/chartkick.js/blob/master/chart.js/chart.esm.js
Chartkick.use(ChartJs);
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
LogarithmicScale,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
ChartJS.register(
CategoryScale,
LogarithmicScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
);
interface TimedValue {
time: Date;
value: number;
pointStyle?: boolean | string;
pointRadius?: number;
}
export type ChartProps = {
@@ -45,47 +65,46 @@ export const Chart: FC<ChartProps> = ({
if (data && data.length > 0) {
renderData.push({
name: title,
color,
id: title,
label: title,
backgroundColor: color,
borderColor: color,
borderWidth: 3,
data: createGraphDataset(data),
});
}
dataCollections.forEach(collection => {
renderData.push({
name: collection.name,
id: collection.name,
label: collection.name,
data: createGraphDataset(collection.data),
color: collection.color,
dataset: collection.options,
backgroundColor: collection.color,
borderColor: collection.color,
borderWidth: 3,
pointStyle: collection.pointStyle || 'circle',
radius: collection.pointRadius || 1,
});
});
// ChartJs.defaults.scales.linear.reverse = true;
const options = {
responsive: true,
scales: {
y: { reverse: false, type: 'linear' },
x: {
type: 'time',
y: {
type: yLogarithmic ? ('logarithmic' as const) : ('linear' as const),
reverse: yFlipped,
title: {
display: true,
text: unit,
},
},
},
};
options.scales.y.reverse = yFlipped;
options.scales.y.type = yLogarithmic ? 'logarithmic' : 'linear';
return (
<div className="line-chart-container">
<LineChart
xtitle="Time"
ytitle={title}
suffix={unit}
legend="bottom"
color={color}
data={renderData}
download={title}
library={options}
/>
<Line data={{ datasets: renderData }} options={options} height="70vh" />
</div>
);
};

View File

@@ -117,7 +117,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
content: (
<div>
List yourself in the Owncast Directory and show off your stream. Enable it in{' '}
<Link href="/config-public-details">settings.</Link>
<Link href="/admin/config/general/">settings.</Link>
</div>
),
});
@@ -129,7 +129,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
title: 'Add your Owncast instance to the Fediverse',
content: (
<div>
<Link href="/config-federation">Enable Owncast social</Link> features to have your
<Link href="/admin/config-federation/">Enable Owncast social</Link> features to have your
instance join the Fediverse, allowing people to follow, share and engage with your live
stream.
</div>

View File

@@ -33,7 +33,7 @@ export const ConfigNotify = () => {
</span>
</p>
<Link passHref href="/config-federation">
<Link passHref href="/admin/config-federation/">
<Button
type="primary"
style={{

View File

@@ -8,7 +8,7 @@ export default {
title: 'owncast/Chat/Chat messages container',
component: ChatContainer,
parameters: {
chromatic: { diffThreshold: 0.2 },
chromatic: { diffThreshold: 0.8 },
docs: {
description: {
component: `

View File

@@ -1,7 +1,4 @@
.root {
padding: 10px 0px;
text-align: center;
font-size: 0.8rem;
font-style: italic;
color: var(--theme-color-components-chat-text);
}

View File

@@ -1,7 +1,7 @@
import { FC } from 'react';
import dynamic from 'next/dynamic';
import { ChatUserBadge } from '../ChatUserBadge/ChatUserBadge';
import styles from './ChatJoinMessage.module.scss';
import { ModerationBadge } from '../ChatUserBadge/ModerationBadge';
// Lazy loaded components
@@ -31,7 +31,7 @@ export const ChatJoinMessage: FC<ChatJoinMessageProps> = ({
<span style={{ fontWeight: 'bold' }}>{displayName}</span>
{isAuthorModerator && (
<span>
<ChatUserBadge badge="mod" userColor={userColor} />
<ModerationBadge userColor={userColor} />
</span>
)}
</span>{' '}

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
.badge {
font-family: var(--theme-text-display-font-family);
font-weight: 500;
font-size: 0.5rem;
text-transform: uppercase;
color: white;
background-color: var(--color-owncast-palette-0);
height: 18px;
width: 18px;
border-radius: 2px;
text-align: center;
padding: 2px;
padding-top: 0px;
padding-bottom: 0px;
border-radius: 3px;
border-width: 1px;
border-style: solid;
margin-left: 3px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
margin-left: 5px;
font-size: 0.75rem;
}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ChatUserBadge } from './ChatUserBadge';
import { ModerationBadge } from './ModerationBadge';
import { AuthedUserBadge } from './AuthedUserBadge';
export default {
title: 'owncast/Chat/Messages/User Flag',
@@ -14,15 +16,26 @@ export default {
} as ComponentMeta<typeof ChatUserBadge>;
const Template: ComponentStory<typeof ChatUserBadge> = args => <ChatUserBadge {...args} />;
const ModerationTemplate: ComponentStory<typeof ModerationBadge> = args => (
<ModerationBadge {...args} />
);
export const Moderator = Template.bind({});
const AuthedTemplate: ComponentStory<typeof ModerationBadge> = args => (
<AuthedUserBadge {...args} />
);
export const Authenticated = AuthedTemplate.bind({});
Authenticated.args = {
userColor: '3',
};
export const Moderator = ModerationTemplate.bind({});
Moderator.args = {
badge: 'mod',
userColor: '5',
};
export const Authenticated = Template.bind({});
Authenticated.args = {
badge: 'auth',
export const Generic = Template.bind({});
Generic.args = {
badge: '?',
userColor: '6',
};

View File

@@ -4,14 +4,15 @@ import styles from './ChatUserBadge.module.scss';
export type ChatUserBadgeProps = {
badge: React.ReactNode;
userColor: number;
title: string;
};
export const ChatUserBadge: FC<ChatUserBadgeProps> = ({ badge, userColor }) => {
const color = `var(--theme-user-colors-${userColor})`;
const style = { color, borderColor: color };
export const ChatUserBadge: FC<ChatUserBadgeProps> = ({ badge, userColor, title }) => {
const color = `var(--theme-color-users-${userColor})`;
const style = { color };
return (
<span style={style} className={styles.badge}>
<span style={style} className={styles.badge} title={title}>
{badge}
</span>
);

View File

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

View File

@@ -25,9 +25,18 @@ $p-size: 8px;
position: relative;
mark {
padding-left: 0.35em;
padding-right: 0.35em;
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);
}
a {
color: var(--theme-color-palette-12);
&:hover {
color: var(--theme-color-palette-4);
}
}
}

View File

@@ -43,6 +43,21 @@ const standardMessage: ChatMessage = JSON.parse(`{
},
"body": "Test message from a regular user."}`);
const messageWithLinkAndCustomEmoji: ChatMessage = JSON.parse(`{
"type": "CHAT",
"id": "wY-MEXwnR",
"timestamp": "2022-04-28T20:30:27.001762726Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 3,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": ["gifted-nobel", "EliteMooseTaskForce"],
"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'/> ."}`);
const moderatorMessage: ChatMessage = JSON.parse(`{
"type": "CHAT",
"id": "wY-MEXwnR",
@@ -80,6 +95,12 @@ WithoutModeratorMenu.args = {
showModeratorMenu: false,
};
export const WithLinkAndCustomEmoji = Template.bind({});
WithLinkAndCustomEmoji.args = {
message: messageWithLinkAndCustomEmoji,
showModeratorMenu: false,
};
export const WithModeratorMenu = Template.bind({});
WithModeratorMenu.args = {
message: standardMessage,

View File

@@ -1,24 +1,22 @@
/* eslint-disable react/no-danger */
import { FC, ReactNode, useEffect, useState } from 'react';
import { FC, ReactNode } from 'react';
import cn from 'classnames';
import { Tooltip } from 'antd';
import { useRecoilValue } from 'recoil';
import dynamic from 'next/dynamic';
import { decodeHTML } from 'entities';
import linkifyHtml from 'linkify-html';
import { Interweave } from 'interweave';
import { UrlMatcher } from 'interweave-autolink';
import { ChatMessageHighlightMatcher } from './customMatcher';
import styles from './ChatUserMessage.module.scss';
import { formatTimestamp } from './messageFmt';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatUserBadge } from '../ChatUserBadge/ChatUserBadge';
import { accessTokenAtom } from '../../stores/ClientConfigStore';
import { User } from '../../../interfaces/user.model';
import { AuthedUserBadge } from '../ChatUserBadge/AuthedUserBadge';
import { ModerationBadge } from '../ChatUserBadge/ModerationBadge';
// Lazy loaded components
const LinkOutlined = dynamic(() => import('@ant-design/icons/LinkOutlined'), {
ssr: false,
});
const ChatModerationActionMenu = dynamic(
() =>
import('../ChatModerationActionMenu/ChatModerationActionMenu').then(
@@ -29,10 +27,6 @@ const ChatModerationActionMenu = dynamic(
},
);
const Highlight = dynamic(() => import('react-highlighter-ts').then(mod => mod.Highlight), {
ssr: false,
});
export type ChatUserMessageProps = {
message: ChatMessage;
showModeratorMenu: boolean;
@@ -74,26 +68,15 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
const color = `var(--theme-color-users-${displayColor})`;
const formattedTimestamp = `Sent ${formatTimestamp(timestamp)}`;
const [formattedMessage, setFormattedMessage] = useState<string>(body);
const badgeNodes = [];
if (isAuthorModerator) {
badgeNodes.push(<ChatUserBadge key="mod" badge="mod" userColor={displayColor} />);
badgeNodes.push(<ModerationBadge key="mod" userColor={displayColor} />);
}
if (isAuthorAuthenticated) {
badgeNodes.push(
<ChatUserBadge
key="auth"
badge={<LinkOutlined title="authenticated" />}
userColor={displayColor}
/>,
);
badgeNodes.push(<AuthedUserBadge key="auth" userColor={displayColor} />);
}
useEffect(() => {
setFormattedMessage(decodeHTML(body));
}, [message]);
return (
<div
className={cn(
@@ -119,12 +102,14 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
</UserTooltip>
)}
<Tooltip title={formattedTimestamp} mouseEnterDelay={1}>
<Highlight search={highlightString}>
<div
className={styles.message}
dangerouslySetInnerHTML={{ __html: linkifyHtml(formattedMessage) }}
/>
</Highlight>
<Interweave
className={styles.message}
content={body}
matchers={[
new UrlMatcher('url', { validateTLD: false }),
new ChatMessageHighlightMatcher('highlight', { highlightString }),
]}
/>
</Tooltip>
{showModeratorMenu && (
<div className={styles.modMenuWrapper}>

View File

@@ -0,0 +1,44 @@
/* eslint-disable class-methods-use-this */
import { ChildrenNode, Matcher, MatchResponse, Node } from 'interweave';
import React from 'react';
export interface CustomProps {
children: React.ReactNode;
key: string;
}
interface options {
highlightString: string;
}
export class ChatMessageHighlightMatcher extends Matcher {
match(str: string): MatchResponse<{}> | null {
const { highlightString } = this.options as options;
if (!highlightString) {
return null;
}
const result = str.match(highlightString);
if (!result) {
return null;
}
return {
index: result.index!,
length: result[0].length,
match: result[0],
valid: true,
};
}
replaceWith(children: ChildrenNode, props: CustomProps): Node {
const { key } = props;
return React.createElement('mark', { key }, children);
}
asTag(): string {
return 'mark';
}
}

View File

@@ -1,38 +1,8 @@
import { convertToText } from '../chat';
import { getDiffInDaysFromNow } from '../../../utils/helpers';
const stripTags = (str: string) => str && str.replace(/<\/?[^>]+(>|$)/g, '');
const convertToMarkup = (str = '') => convertToText(str).replace(/\n/g, '<p></p>');
function getInstagramEmbedFromURL(url: string) {
const urlObject = new URL(url.replace(/\/$/, ''));
urlObject.pathname += '/embed';
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
}
function isMessageJustAnchor(embedText: string, message: string, anchors: HTMLAnchorElement[]) {
if (embedText !== '' && anchors.length === 1) return false;
return stripTags(message) === stripTags(anchors[0]?.innerHTML);
}
function getMessageWithEmbeds(message: string) {
let embedText = '';
// Make a temporary element so we can actually parse the html and pull anchor tags from it.
// This is a better approach than regex.
const container = document.createElement('p');
container.innerHTML = message;
const anchors = Array.from(container.querySelectorAll('a'));
anchors.forEach(({ href }) => {
if (href.includes('instagram.com/p/')) embedText += getInstagramEmbedFromURL(href);
});
// If this message only consists of a single embeddable link
// then only return the embed and strip the link url from the text.
if (isMessageJustAnchor(embedText, message, anchors)) return embedText;
return message + embedText;
}
export function formatTimestamp(sentAt: Date) {
const now = new Date(sentAt);
if (Number.isNaN(now)) return '';
@@ -56,8 +26,6 @@ export function formatTimestamp(sentAt: Date) {
*/
export function formatMessageText(message: string) {
let formattedText = getMessageWithEmbeds(message);
formattedText = convertToMarkup(formattedText);
const formattedText = convertToMarkup(message);
return formattedText;
// return await highlightUsername(formattedText, username);
}

View File

@@ -3,17 +3,18 @@ import cn from 'classnames';
import styles from './OwncastLogo.module.scss';
export type LogoProps = {
variant: 'simple' | 'contrast';
variant?: 'simple' | 'contrast';
className?: string;
};
export const OwncastLogo: FC<LogoProps> = ({ variant = 'simple' }) => {
export const OwncastLogo: FC<LogoProps> = ({ variant = 'simple', className = '' }) => {
const rootClassName = cn(styles.root, {
[styles.simple]: variant === 'simple',
[styles.contrast]: variant === 'contrast',
});
return (
<div className={rootClassName}>
<div className={`${rootClassName} ${className}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 95.68623352050781 104.46271514892578"

View File

@@ -24,6 +24,7 @@ import { Theme } from '../../theme/Theme';
import styles from './Main.module.scss';
import { PushNotificationServiceWorker } from '../../workers/PushNotificationServiceWorker/PushNotificationServiceWorker';
import { AppStateOptions } from '../../stores/application-state';
import { Noscript } from '../../ui/Noscript/Noscript';
const lockBodyStyle = `
body {
@@ -152,6 +153,8 @@ export const Main: FC = () => {
<FatalErrorStateModal title={fatalError.title} message={fatalError.message} />
)}
</Layout>
<Noscript />
</>
);
};

View File

@@ -14,6 +14,7 @@ export default {
title: 'owncast/Modals/Browser Notifications',
component: BrowserNotifyModal,
parameters: {
chromatic: { diffThreshold: 0.7 },
design: {
type: 'image',
url: BrowserNotifyModalMock,

View File

@@ -158,7 +158,7 @@ export const ClientConfigStore: FC = () => {
const [currentUser, setCurrentUser] = useRecoilState(currentUserAtom);
const setChatAuthenticated = useSetRecoilState<boolean>(chatAuthenticatedAtom);
const [clientConfig, setClientConfig] = useRecoilState<ClientConfig>(clientConfigStateAtom);
const [serverStatus, setServerStatus] = useRecoilState<ServerStatus>(serverStatusState);
const [, setServerStatus] = useRecoilState<ServerStatus>(serverStatusState);
const setClockSkew = useSetRecoilState<Number>(clockSkewAtom);
const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom);
const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
@@ -166,7 +166,6 @@ export const ClientConfigStore: FC = () => {
const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom);
const setWebsocketService = useSetRecoilState<WebsocketService>(websocketServiceAtom);
const [hiddenMessageIds, setHiddenMessageIds] = useRecoilState<string[]>(removedMessageIdsAtom);
const [, setHasLoadedStatus] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
let ws: WebsocketService;
@@ -177,21 +176,27 @@ export const ClientConfigStore: FC = () => {
message,
});
};
const sendEvent = (event: string) => {
const sendEvent = (events: string[]) => {
// console.debug('---- sending event:', event);
appStateSend({ type: event });
appStateSend(events);
};
const handleStatusChange = (status: ServerStatus) => {
if (appState.matches('loading')) {
sendEvent(AppStateEvent.Loaded);
const events = [AppStateEvent.Loaded];
if (status.online) {
events.push(AppStateEvent.Online);
} else {
events.push(AppStateEvent.Offline);
}
sendEvent(events);
return;
}
if (status.online && appState.matches('ready')) {
sendEvent(AppStateEvent.Online);
sendEvent([AppStateEvent.Online]);
} else if (!status.online && !appState.matches('ready.offline')) {
sendEvent(AppStateEvent.Offline);
sendEvent([AppStateEvent.Offline]);
}
};
@@ -210,8 +215,9 @@ export const ClientConfigStore: FC = () => {
const updateServerStatus = async () => {
try {
const status = await ServerStatusService.getStatus();
handleStatusChange(status);
setServerStatus(status);
setHasLoadedStatus(true);
const { serverTime } = status;
const clockSkew = new Date(serverTime).getTime() - Date.now();
@@ -219,7 +225,7 @@ export const ClientConfigStore: FC = () => {
setGlobalFatalErrorMessage(null);
} catch (error) {
sendEvent(AppStateEvent.Fail);
sendEvent([AppStateEvent.Fail]);
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
}
@@ -233,7 +239,7 @@ export const ClientConfigStore: FC = () => {
}
try {
sendEvent(AppStateEvent.NeedsRegister);
sendEvent([AppStateEvent.NeedsRegister]);
const response = await ChatService.registerUser(optionalDisplayName);
const { accessToken: newAccessToken, displayName: newDisplayName, displayColor } = response;
if (!newAccessToken) {
@@ -248,7 +254,7 @@ export const ClientConfigStore: FC = () => {
setAccessToken(newAccessToken);
setLocalStorage(ACCESS_TOKEN_KEY, newAccessToken);
} catch (e) {
sendEvent(AppStateEvent.Fail);
sendEvent([AppStateEvent.Fail]);
console.error(`ChatService -> registerUser() ERROR: \n${e}`);
}
};
@@ -356,17 +362,13 @@ export const ClientConfigStore: FC = () => {
if ((window as any).statusHydration) {
const status = JSON.parse((window as any).statusHydration);
setServerStatus(status);
setHasLoadedStatus(true);
handleStatusChange(status);
}
} catch (e) {
console.error('error parsing status hydration', e);
}
}, []);
useEffect(() => {
handleStatusChange(serverStatus);
}, [serverStatus]);
useEffect(() => {
if (!clientConfig.chatDisabled && accessToken && hasLoadedConfig) {
startChat();

View File

@@ -77,6 +77,7 @@ const OwncastPlayer = dynamic(
() => import('../../video/OwncastPlayer/OwncastPlayer').then(mod => mod.OwncastPlayer),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 12 }} />,
},
);
@@ -103,7 +104,7 @@ const DesktopContent = ({
}) => {
const aboutTabContent = <CustomPageContent content={extraPageContent} />;
const followersTabContent = (
<div style={{ minHeight: '16vh' }}>
<div>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
@@ -343,16 +344,18 @@ export const Content: FC = () => {
/>
)}
{!online && !appState.appLoading && (
<OfflineBanner
showsHeader={false}
streamName={name}
customText={offlineMessage}
notificationsEnabled={browserNotificationsEnabled}
fediverseAccount={fediverseAccount}
lastLive={lastDisconnectTime}
onNotifyClick={() => setShowNotifyModal(true)}
onFollowClick={() => setShowFollowModal(true)}
/>
<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

View File

@@ -6,7 +6,9 @@ import { Header } from './Header';
export default {
title: 'owncast/Layout/Header',
component: Header,
parameters: {},
parameters: {
chromatic: { diffThreshold: 0.75 },
},
} as ComponentMeta<typeof Header>;
const Template: ComponentStory<typeof Header> = args => (

View File

@@ -29,10 +29,14 @@ export const Header: FC<HeaderComponentProps> = ({
online,
}) => (
<header className={cn([`${styles.header}`], 'global-header')}>
{online && (
{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

View File

@@ -24,7 +24,7 @@
.image {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
object-fit: cover;
object-position: center;
overflow: hidden;
}

View File

@@ -0,0 +1,50 @@
@import '../../../styles/mixins.scss';
.noscript {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 2em;
font-size: large;
background-color: var(--theme-color-background-main);
z-index: 999;
h2 {
margin-top: 25px;
margin-bottom: 5px;
font-weight: bold;
font-size: inherit;
}
}
// Necessary in case content y-overflows becuase
// align-items: center would otherwise hide some
// of the content
.scrollContainer {
max-height: 100%;
overflow: auto;
}
.content {
max-width: 100%;
width: 70ch;
display: flex;
flex-direction: column;
align-items: center;
}
.logo {
width: 70%;
// For some weir reason, the logo isn't displayed on screens <= 767px.
// This coincides with the tablet breakpoint, but god knows what exactly
// the issue is. Since it's just a design element, just hide the logo on
// those smaller screens. For more information, see
// https://github.com/owncast/owncast/pull/2592
@include screen(tablet) {
display: none;
}
}

View File

@@ -0,0 +1,43 @@
import { FC } from 'react';
import { OwncastLogo } from '../../common/OwncastLogo/OwncastLogo';
import styles from './Noscript.module.scss';
export const Noscript: FC = () => (
<noscript className={styles.noscript}>
<div className={styles.scrollContainer}>
<div className={styles.content}>
<OwncastLogo className={styles.logo} />
<br />
<p>
This website is powered by&nbsp;
<a href="https://owncast.online" rel="noopener noreferrer" target="_blank">
Owncast
</a>
.
</p>
<p>
Owncast uses JavaScript for playing the HTTP Live Streaming (HLS) video, and its chat
client. But your web browser does not seem to support JavaScript, or you have it disabled.
</p>
<p>
For the best experience, you should use a different browser with JavaScript support. If
you have disabled JavaScript in your browser, you can re-enable it.
</p>
<h2>How can I watch this stream without JavaScript?</h2>
<p>
You can open the URL of this website in your media player (such as&nbsp;
<a href="https://mpv.io" rel="noopener noreferrer" target="_blank">
mpv
</a>
&nbsp;or&nbsp;
<a href="https://www.videolan.org/vlc/" rel="noopener noreferrer" target="_blank">
VLC
</a>
) to watch the stream.
</p>
<h2>How can I chat with the others without JavaScript?</h2>
<p>Currently, there is no option to use the chat without JavaScript.</p>
</div>
</div>
</noscript>
);

View File

@@ -1,3 +1,5 @@
@import '../../../styles/mixins.scss';
.outerContainer {
display: flex;
justify-content: center;
@@ -11,9 +13,16 @@
background-color: var(--theme-color-background-main);
margin: 3rem auto;
border-radius: var(--theme-rounded-corners);
padding: 2.5em;
font-size: 1.2rem;
padding: 2.4em;
font-size: 1.3rem;
border: 1px solid lightgray;
font-family: var(--theme-text-display-font-family);
@include screen(tablet) {
font-size: 1.2rem;
padding: 1em;
margin: 1rem auto;
}
}
.bodyText {
@@ -29,6 +38,7 @@
margin-top: 15px;
font-size: 1rem;
opacity: 0.5;
font-family: var(--theme-text-body-font-family);
.clockIcon {
margin-right: 5px;

View File

@@ -3,6 +3,7 @@ import intervalToDuration from 'date-fns/intervalToDuration';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import styles from './Statusbar.module.scss';
import { pluralize } from '../../../utils/helpers';
// Lazy loaded components
@@ -18,15 +19,23 @@ export type StatusbarProps = {
};
function makeDurationString(lastConnectTime: Date): string {
const DAY_LABEL = 'day';
const HOUR_LABEL = 'hour';
const MINUTE_LABEL = 'minute';
const SECOND_LABEL = 'second';
const diff = intervalToDuration({ start: lastConnectTime, end: new Date() });
if (diff.days > 1) {
return `${diff.days} days ${diff.hours} hours`;
if (diff.days >= 1) {
return `${diff.days} ${pluralize(DAY_LABEL, diff.days)}
${diff.hours} ${pluralize(HOUR_LABEL, diff.hours)}`;
}
if (diff.hours >= 1) {
return `${diff.hours} hours ${diff.minutes} minutes`;
return `${diff.hours} ${pluralize(HOUR_LABEL, diff.hours)} ${diff.minutes}
${pluralize(MINUTE_LABEL, diff.minutes)}`;
}
return `${diff.minutes} minutes ${diff.seconds} seconds`;
return `${diff.minutes} ${pluralize(MINUTE_LABEL, diff.minutes)}
${diff.seconds} ${pluralize(SECOND_LABEL, diff.seconds)}`;
}
export const Statusbar: FC<StatusbarProps> = ({

View File

@@ -27,7 +27,7 @@
.account {
color: var(--theme-color-components-text-on-light);
word-break: break-all;
line-height: 0.9rem;
line-height: 1rem;
}
@include screen(mobile) {

View File

@@ -1,4 +1,4 @@
import { Avatar, Col, Row } from 'antd';
import { Avatar, Col, Row, Typography } from 'antd';
import React, { FC } from 'react';
import cn from 'classnames';
import { Follower } from '../../../../interfaces/follower';
@@ -17,9 +17,13 @@ export const SingleFollower: FC<SingleFollowerProps> = ({ follower }) => (
<img src="/logo" alt="Logo" className={styles.placeholder} />
</Avatar>
</Col>
<Col>
<Row className={styles.name}>{follower.name}</Row>
<Row className={styles.account}>{follower.username}</Row>
<Col span={18}>
<Row className={styles.name}>
<Typography.Text ellipsis>{follower.name}</Typography.Text>
</Row>
<Row className={styles.account}>
<Typography.Text ellipsis>{follower.username}</Typography.Text>
</Row>
</Col>
</Row>
</a>

View File

@@ -4,9 +4,14 @@
display: grid;
width: 100%;
justify-items: center;
max-height: 75vh;
height: 75vh;
aspect-ratio: 16 / 9;
@media (max-width: 1200px) {
height: unset;
max-height: 75vh;
}
.player,
.poster {
width: 100%;

View File

@@ -1,23 +1 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 46 42" style="enable-background:new 0 0 46 42;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_2_);}
</style>
<g>
<defs>
<path id="SVGID_1_" d="M22.2,24.2L8.5,39.9c-0.5,0.6-0.1,1.5,0.7,1.5h27.5c0.8,0,1.2-0.9,0.7-1.5L23.8,24.2
c-0.2-0.2-0.5-0.4-0.8-0.4C22.7,23.8,22.4,23.9,22.2,24.2 M6.5,0.6c-2.3,0-3.1,0.2-3.9,0.7C1.8,1.7,1.1,2.4,0.7,3.2
C0.2,4,0,4.9,0,7.1v17.5c0,2.3,0.2,3.1,0.7,3.9c0.4,0.8,1.1,1.5,1.9,1.9c0.8,0.4,1.7,0.7,3.9,0.7h5.2l2.2-2.6H5.8
c-1.1,0-1.6-0.1-2-0.3c-0.4-0.2-0.7-0.5-1-1c-0.2-0.4-0.3-0.8-0.3-2v-19c0-1.1,0.1-1.6,0.3-2c0.2-0.4,0.5-0.7,1-1
c0.4-0.2,0.8-0.3,2-0.3h34.3c1.1,0,1.6,0.1,2,0.3c0.4,0.2,0.7,0.5,1,1c0.2,0.4,0.3,0.8,0.3,2v19c0,1.1-0.1,1.6-0.3,2
c-0.2,0.4-0.5,0.7-1,1c-0.4,0.2-0.8,0.3-2,0.3H32l2.2,2.6h5.2c2.3,0,3.1-0.2,3.9-0.7c0.8-0.4,1.5-1.1,1.9-1.9
c0.4-0.8,0.7-1.7,0.7-3.9V7.1c0-2.3-0.2-3.1-0.7-3.9c-0.4-0.8-1.1-1.5-1.9-1.9c-0.8-0.4-1.7-0.7-3.9-0.7H6.5z"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<rect x="-6.4" y="-5.8" class="st0" width="58.7" height="53.6"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 46 42" style="enable-background:new 0 0 46 42" xml:space="preserve"><style type="text/css">.st0{clip-path:url(#SVGID_2_)}</style><g><defs><path id="SVGID_1_" d="M22.2,24.2L8.5,39.9c-0.5,0.6-0.1,1.5,0.7,1.5h27.5c0.8,0,1.2-0.9,0.7-1.5L23.8,24.2 c-0.2-0.2-0.5-0.4-0.8-0.4C22.7,23.8,22.4,23.9,22.2,24.2 M6.5,0.6c-2.3,0-3.1,0.2-3.9,0.7C1.8,1.7,1.1,2.4,0.7,3.2 C0.2,4,0,4.9,0,7.1v17.5c0,2.3,0.2,3.1,0.7,3.9c0.4,0.8,1.1,1.5,1.9,1.9c0.8,0.4,1.7,0.7,3.9,0.7h5.2l2.2-2.6H5.8 c-1.1,0-1.6-0.1-2-0.3c-0.4-0.2-0.7-0.5-1-1c-0.2-0.4-0.3-0.8-0.3-2v-19c0-1.1,0.1-1.6,0.3-2c0.2-0.4,0.5-0.7,1-1 c0.4-0.2,0.8-0.3,2-0.3h34.3c1.1,0,1.6,0.1,2,0.3c0.4,0.2,0.7,0.5,1,1c0.2,0.4,0.3,0.8,0.3,2v19c0,1.1-0.1,1.6-0.3,2 c-0.2,0.4-0.5,0.7-1,1c-0.4,0.2-0.8,0.3-2,0.3H32l2.2,2.6h5.2c2.3,0,3.1-0.2,3.9-0.7c0.8-0.4,1.5-1.1,1.9-1.9 c0.4-0.8,0.7-1.7,0.7-3.9V7.1c0-2.3-0.2-3.1-0.7-3.9c-0.4-0.8-1.1-1.5-1.9-1.9c-0.8-0.4-1.7-0.7-3.9-0.7H6.5z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" style="overflow:visible"/></clipPath><rect width="58.7" height="53.6" x="-6.4" y="-5.8" class="st0"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB