diff --git a/web/components/main-layout.tsx b/web/components/main-layout.tsx
index 37bac402c..80a617bd9 100644
--- a/web/components/main-layout.tsx
+++ b/web/components/main-layout.tsx
@@ -16,6 +16,7 @@ import {
QuestionCircleOutlined,
MessageOutlined,
ExperimentOutlined,
+ UserOutlined,
} from '@ant-design/icons';
import classNames from 'classnames';
import { upgradeVersionAvailable } from '../utils/apis';
@@ -78,6 +79,8 @@ export default function MainLayout(props) {
const upgradeVersionString = `${upgradeVersion}` || '';
const upgradeMessage = `Upgrade to v${upgradeVersionString}`;
+ const chatMenuItemStyle = 'block'; //upgradeVersion ? 'block' : 'none';
+
const clearAlertMessage = () => {
alertMessage.setMessage(null);
};
@@ -144,9 +147,21 @@ export default function MainLayout(props) {
Viewers
-
} title="Chat utilities">
- Chat
-
+ }
+ style={{ display: chatMenuItemStyle }}
+ >
+ } title="Chat utilities">
+ Messages
+
+
+ } title="Chat utilities">
+ Users
+
+
+
}>
@@ -159,6 +174,9 @@ export default function MainLayout(props) {
Video Configuration
+
+ Chat
+
Storage
diff --git a/web/components/message-visiblity-toggle.tsx b/web/components/message-visiblity-toggle.tsx
index 0e428e812..cd93c19f2 100644
--- a/web/components/message-visiblity-toggle.tsx
+++ b/web/components/message-visiblity-toggle.tsx
@@ -9,7 +9,7 @@ import {
} from '@ant-design/icons';
import { fetchData, UPDATE_CHAT_MESSGAE_VIZ } from '../utils/apis';
import { MessageType } from '../types/chat';
-import { OUTCOME_TIMEOUT } from '../pages/chat';
+import { OUTCOME_TIMEOUT } from '../pages/chat/messages';
import { isEmptyObject } from '../utils/format';
interface MessageToggleProps {
diff --git a/web/pages/chat.tsx b/web/pages/chat/messages.tsx
similarity index 96%
rename from web/pages/chat.tsx
rename to web/pages/chat/messages.tsx
index 7d01e373e..5916bf11b 100644
--- a/web/pages/chat.tsx
+++ b/web/pages/chat/messages.tsx
@@ -5,10 +5,10 @@ import classNames from 'classnames';
import { ColumnsType } from 'antd/es/table';
import format from 'date-fns/format';
-import { CHAT_HISTORY, fetchData, FETCH_INTERVAL, UPDATE_CHAT_MESSGAE_VIZ } from '../utils/apis';
-import { MessageType } from '../types/chat';
-import { isEmptyObject } from '../utils/format';
-import MessageVisiblityToggle from '../components/message-visiblity-toggle';
+import { CHAT_HISTORY, fetchData, FETCH_INTERVAL, UPDATE_CHAT_MESSGAE_VIZ } from '../../utils/apis';
+import { MessageType } from '../../types/chat';
+import { isEmptyObject } from '../../utils/format';
+import MessageVisiblityToggle from '../../components/message-visiblity-toggle';
const { Title } = Typography;
diff --git a/web/pages/chat/users.tsx b/web/pages/chat/users.tsx
new file mode 100644
index 000000000..d340be472
--- /dev/null
+++ b/web/pages/chat/users.tsx
@@ -0,0 +1,114 @@
+import React, { useState, useEffect, useContext } from 'react';
+import { Table, Typography } from 'antd';
+import { formatDistanceToNow } from 'date-fns';
+import { SortOrder } from 'antd/lib/table/interface';
+
+import { ServerStatusContext } from '../../utils/server-status-context';
+
+import { CONNECTED_CLIENTS, VIEWERS_OVER_TIME, fetchData } from '../../utils/apis';
+
+const FETCH_INTERVAL = 60 * 1000; // 1 min
+
+export default function ChatUsers() {
+ const context = useContext(ServerStatusContext);
+ const { online } = context || {};
+
+ const [viewerInfo, setViewerInfo] = useState([]);
+ const [clients, setClients] = useState([]);
+
+ const getInfo = async () => {
+ try {
+ const result = await fetchData(VIEWERS_OVER_TIME);
+ setViewerInfo(result);
+ } catch (error) {
+ console.log('==== error', error);
+ }
+
+ try {
+ const result = await fetchData(CONNECTED_CLIENTS);
+ setClients(result);
+ } catch (error) {
+ console.log('==== error', error);
+ }
+ };
+
+ useEffect(() => {
+ let getStatusIntervalId = null;
+
+ getInfo();
+ if (online) {
+ getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
+ // returned function will be called on component unmount
+ return () => {
+ clearInterval(getStatusIntervalId);
+ };
+ }
+
+ return () => [];
+ }, [online]);
+
+ // todo - check to see if broadcast active has changed. if so, start polling.
+
+ if (!viewerInfo.length) {
+ return 'no info';
+ }
+
+ const columns = [
+ {
+ title: 'User name',
+ dataIndex: 'username',
+ key: 'username',
+ render: username => username || '-',
+ sorter: (a, b) => a.username - b.username,
+ sortDirections: ['descend', 'ascend'] as SortOrder[],
+ },
+ {
+ title: 'Messages sent',
+ dataIndex: 'messageCount',
+ key: 'messageCount',
+ sorter: (a, b) => a.messageCount - b.messageCount,
+ sortDirections: ['descend', 'ascend'] as SortOrder[],
+ },
+ {
+ title: 'Connected Time',
+ dataIndex: 'connectedAt',
+ key: 'connectedAt',
+ render: time => formatDistanceToNow(new Date(time)),
+ sorter: (a, b) => new Date(a.connectedAt).getTime() - new Date(b.connectedAt).getTime(),
+ sortDirections: ['descend', 'ascend'] as SortOrder[],
+ },
+ {
+ title: 'User Agent',
+ dataIndex: 'userAgent',
+ key: 'userAgent',
+ },
+ {
+ title: 'Location',
+ dataIndex: 'geo',
+ key: 'geo',
+ render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'),
+ },
+ ];
+
+ return (
+ <>
+
+
Connected
+
row.clientID} />
+
+
+ Visit the{' '}
+
+ documentation
+ {' '}
+ to configure additional details about your viewers.
+ {' '}
+
+
+ >
+ );
+}
diff --git a/web/pages/config-chat.tsx b/web/pages/config-chat.tsx
new file mode 100644
index 000000000..351cd3398
--- /dev/null
+++ b/web/pages/config-chat.tsx
@@ -0,0 +1,69 @@
+import React, { useState, useContext, useEffect } from 'react';
+import { Typography } from 'antd';
+import {
+ FIELD_PROPS_DISABLE_CHAT,
+ TEXTFIELD_PROPS_CHAT_USERNAME_BLOCKLIST,
+} from '../utils/config-constants';
+import { ServerStatusContext } from '../utils/server-status-context';
+import ToggleSwitch from '../components/config/form-toggleswitch';
+import { UpdateArgs } from '../types/config-section';
+import { TEXTFIELD_TYPE_TEXTAREA } from '../components/config/form-textfield';
+import TextFieldWithSubmit from '../components/config/form-textfield-with-submit';
+
+export default function ConfigChat() {
+ const { Title } = Typography;
+ const [formDataValues, setFormDataValues] = useState(null);
+ const serverStatusData = useContext(ServerStatusContext);
+
+ const { serverConfig } = serverStatusData || {};
+ const { chatDisabled } = serverConfig;
+ const { usernameBlocklist } = serverConfig;
+
+ function handleChatDisableChange(disabled: boolean) {
+ handleFieldChange({ fieldName: 'chatDisabled', value: disabled });
+ }
+
+ function handleChatUsernameBlockListChange(args: UpdateArgs) {
+ handleFieldChange({ fieldName: 'usernameBlocklist', value: args.value });
+ }
+
+ useEffect(() => {
+ setFormDataValues({
+ chatDisabled,
+ usernameBlocklist,
+ });
+ }, [serverConfig]);
+
+ if (!formDataValues) {
+ return null;
+ }
+
+ const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
+ setFormDataValues({
+ ...formDataValues,
+ [fieldName]: value,
+ });
+ };
+
+ return (
+
+
Chat Settings
+
+
+
+
+
+ );
+}
diff --git a/web/types/config-section.ts b/web/types/config-section.ts
index e18d4bcc4..5b6fd511f 100644
--- a/web/types/config-section.ts
+++ b/web/types/config-section.ts
@@ -101,4 +101,5 @@ export interface ConfigDetails {
yp: ConfigDirectoryFields;
supportedCodecs: string[];
videoCodec: string;
+ usernameBlocklist: string;
}
diff --git a/web/utils/config-constants.tsx b/web/utils/config-constants.tsx
index c5015f1cc..2ff13482b 100644
--- a/web/utils/config-constants.tsx
+++ b/web/utils/config-constants.tsx
@@ -30,6 +30,7 @@ export const API_VIDEO_VARIANTS = '/video/streamoutputvariants';
export const API_WEB_PORT = '/webserverport';
export const API_YP_SWITCH = '/directoryenabled';
export const API_CHAT_DISABLE = '/chat/disable';
+export const API_CHAT_USERNAME_BLOCKLIST = '/chat/disallowedusernames';
export const API_EXTERNAL_ACTIONS = '/externalactions';
export const API_VIDEO_CODEC = '/video/codec';
@@ -175,11 +176,18 @@ export const DEFAULT_VARIANT_STATE: VideoVariant = {
export const FIELD_PROPS_DISABLE_CHAT = {
apiPath: API_CHAT_DISABLE,
configPath: 'chatDisabled',
- label: 'Hide chat',
- tip: 'Hide the web chat interface.',
+ label: 'Disable chat',
+ tip: 'Disable chat functionality from your Owncast server.',
useSubmit: true,
};
+export const TEXTFIELD_PROPS_CHAT_USERNAME_BLOCKLIST = {
+ apiPath: API_CHAT_USERNAME_BLOCKLIST,
+ placeholder: 'admin, god, owncast, stewiegriffin',
+ label: 'Disallowed usernames',
+ tip: 'A comma seperated list of chat usernames you disallow.',
+};
+
export const VIDEO_VARIANT_SETTING_DEFAULTS = {
// this one is currently unused
audioBitrate: {
diff --git a/web/utils/server-status-context.tsx b/web/utils/server-status-context.tsx
index 9e61d13d7..cca2b135e 100644
--- a/web/utils/server-status-context.tsx
+++ b/web/utils/server-status-context.tsx
@@ -48,6 +48,7 @@ export const initialServerConfigState: ConfigDetails = {
externalActions: [],
supportedCodecs: [],
videoCodec: '',
+ usernameBlocklist: '',
};
const initialServerStatusState = {