Merge branch 'develop' into fix/ImplementPasswordRules
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ConfigNotify = () => {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<Link passHref href="/config-federation">
|
||||
<Link passHref href="/admin/config-federation/">
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>{' '}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
component: ChatTextField,
|
||||
parameters: {
|
||||
fetchMock: mocks,
|
||||
chromatic: { diffThreshold: 0.2 },
|
||||
chromatic: { diffThreshold: 0.8 },
|
||||
|
||||
design: {
|
||||
type: 'image',
|
||||
|
||||
17
web/components/chat/ChatUserBadge/AuthedUserBadge.tsx
Normal file
17
web/components/chat/ChatUserBadge/AuthedUserBadge.tsx
Normal 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" />
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
17
web/components/chat/ChatUserBadge/ModerationBadge.tsx
Normal file
17
web/components/chat/ChatUserBadge/ModerationBadge.tsx
Normal 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" />
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
44
web/components/chat/ChatUserMessage/customMatcher.ts
Normal file
44
web/components/chat/ChatUserMessage/customMatcher.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
title: 'owncast/Modals/Browser Notifications',
|
||||
component: BrowserNotifyModal,
|
||||
parameters: {
|
||||
chromatic: { diffThreshold: 0.7 },
|
||||
design: {
|
||||
type: 'image',
|
||||
url: BrowserNotifyModalMock,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
50
web/components/ui/Noscript/Noscript.module.scss
Normal file
50
web/components/ui/Noscript/Noscript.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
43
web/components/ui/Noscript/Noscript.tsx
Normal file
43
web/components/ui/Noscript/Noscript.tsx
Normal 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
|
||||
<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
|
||||
<a href="https://mpv.io" rel="noopener noreferrer" target="_blank">
|
||||
mpv
|
||||
</a>
|
||||
or
|
||||
<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>
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user