diff --git a/web/package-lock.json b/web/package-lock.json index 9ba0310bd..0fc497d0b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1575,6 +1575,12 @@ "moment": "^2.10.2" } }, + "@types/classnames": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz", + "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==", + "dev": true + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", diff --git a/web/package.json b/web/package.json index 2e7d684aa..f61e05f81 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@types/chart.js": "^2.9.28", + "@types/classnames": "^2.2.11", "@types/node": "^14.11.2", "@types/prop-types": "^15.7.3", "@types/react": "^16.9.49", diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index c427e14da..96a334ff2 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -4,6 +4,7 @@ import '../styles/globals.scss'; // GW: I can't override ant design styles through components using NextJS's built-in CSS modules. So I'll just import styles here for now and figure out enabling SASS modules later. import '../styles/home.scss'; +import '../styles/chat.scss'; import { AppProps } from 'next/app'; import ServerStatusProvider from '../utils/server-status-context'; diff --git a/web/pages/chat.tsx b/web/pages/chat.tsx new file mode 100644 index 000000000..fe7d1bdff --- /dev/null +++ b/web/pages/chat.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from "react"; +import { Table, Typography, Tooltip, Button } from "antd"; +import { CheckCircleFilled, ExclamationCircleFilled, StopOutlined } from "@ant-design/icons"; +import classNames from 'classnames'; +import { ColumnsType } from 'antd/es/table'; +import format from 'date-fns/format' + +import { CHAT_HISTORY, fetchData, UPDATE_CHAT_MESSGAE_VIZ } from "../utils/apis"; +import { MessageType } from '../types/chat'; +import { isEmptyObject } from "../utils/format"; + +const { Title } = Typography; + +function createUserNameFilters(messages: MessageType[]) { + const filtered = messages.reduce((acc, curItem) => { + const curAuthor = curItem.author; + if (!acc.some(item => item.text === curAuthor)) { + acc.push({ text: curAuthor, value: curAuthor }); + } + return acc; + }, []); + + // sort by name + return filtered.sort((a, b) => { + const nameA = a.text.toUpperCase(); // ignore upper and lowercase + const nameB = b.text.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + // names must be equal + return 0; + }); +} +export const OUTCOME_TIMEOUT = 3000; + +export default function Chat() { + const [messages, setMessages] = useState([]); + const [selectedRowKeys, setSelectedRows] = useState([]); + const [bulkProcessing, setBulkProcessing] = useState(false); + const [bulkOutcome, setBulkOutcome] = useState(null); + const [bulkAction, setBulkAction] = useState(''); + let outcomeTimeout = null; + + const getInfo = async () => { + try { + const result = await fetchData(CHAT_HISTORY, { auth: true }); + if (isEmptyObject(result)) { + setMessages([]); + } else { + setMessages(result); + } + } catch (error) { + console.log("==== error", error); + } + }; + + useEffect(() => { + getInfo(); + return () => { + clearTimeout(outcomeTimeout); + }; + }, []); + + const nameFilters = createUserNameFilters(messages); + + const rowSelection = { + selectedRowKeys, + onChange: (selectedKeys: string[]) => { + setSelectedRows(selectedKeys); + }, + }; + + + const resetBulkOutcome = () => { + outcomeTimeout = setTimeout(() => { + setBulkOutcome(null); + setBulkAction(''); + }, OUTCOME_TIMEOUT); + }; + const handleSubmitBulk = async (bulkVisibility) => { + setBulkProcessing(true); + const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, { + auth: true, + method: 'POST', + data: { + visible: bulkVisibility, + idArray: selectedRowKeys, + }, + }); + + if (result.success && result.message === "changed") { + setBulkOutcome(); + resetBulkOutcome(); + + // update messages + const updatedList = [...messages]; + selectedRowKeys.map(key => { + const messageIndex = updatedList.findIndex(m => m.id === key); + const newMessage = {...messages[messageIndex], visible: bulkVisibility }; + updatedList.splice(messageIndex, 1, newMessage); + return null; + }); + setMessages(updatedList); + setSelectedRows([]); + } else { + setBulkOutcome(); + resetBulkOutcome(); + } + setBulkProcessing(false); + } + const handleSubmitBulkShow = () => { + setBulkAction('show'); + handleSubmitBulk(true); + } + const handleSubmitBulkHide = () => { + setBulkAction('hide'); + handleSubmitBulk(false); + } + + const chatColumns: ColumnsType = [ + { + title: 'Time', + dataIndex: 'timestamp', + key: 'timestamp', + className: 'timestamp-col', + defaultSortOrder: 'descend', + render: (timestamp) => { + const dateObject = new Date(timestamp); + return format(dateObject, 'PP pp'); + }, + sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + width: 90, + }, + { + title: 'User', + dataIndex: 'author', + key: 'author', + className: 'name-col', + filters: nameFilters, + onFilter: (value, record) => record.author === value, + sorter: (a, b) => a.author.localeCompare(b.author), + sortDirections: ['ascend', 'descend'], + ellipsis: true, + render: author => ( + + {author} + + ), + width: 110, + }, + { + title: 'Message', + dataIndex: 'body', + key: 'body', + className: 'message-col', + width: 320, + render: body => ( +
+ ) + }, + { + title: '', + dataIndex: 'visible', + key: 'visible', + className: 'toggle-col', + filters: [{ text: 'Visible messages', value: true }, { text: 'Hidden messages', value: false }], + onFilter: (value, record) => record.visible === value, + render: visible => visible ? null : , + width: 30, + }, + ]; + + const bulkDivClasses = classNames({ + 'bulk-editor': true, + active: selectedRowKeys.length, + }); + + return ( +
+ Chat Messages +

Manage the messages from viewers that show up on your stream.

+
+ Check multiple messages to change their visibility to: + + + +
+ !record.visible ? 'hidden' : ''} + dataSource={messages} + columns={chatColumns} + rowKey={(row) => row.id} + rowSelection={rowSelection} + /> + ) +} + + diff --git a/web/pages/components/chart.tsx b/web/pages/components/chart.tsx index 019411d1f..e6cbeab3b 100644 --- a/web/pages/components/chart.tsx +++ b/web/pages/components/chart.tsx @@ -1,7 +1,7 @@ import { LineChart } from 'react-chartkick' -import styles from '../../styles/styles.module.scss'; import 'chart.js'; -import format from 'date-fns/format' +import format from 'date-fns/format'; +import styles from '../../styles/styles.module.scss'; interface TimedValue { time: Date; diff --git a/web/pages/components/main-layout.tsx b/web/pages/components/main-layout.tsx index 1139d679c..1e9aae6a8 100644 --- a/web/pages/components/main-layout.tsx +++ b/web/pages/components/main-layout.tsx @@ -13,7 +13,8 @@ import { ToolOutlined, PlayCircleFilled, MinusSquareFilled, - QuestionCircleOutlined + QuestionCircleOutlined, + MessageOutlined } from '@ant-design/icons'; import classNames from 'classnames'; import { upgradeVersionAvailable } from "../../utils/apis"; @@ -108,6 +109,14 @@ export default function MainLayout(props) { Viewers + } + title="Chat utilities" + > + Chat + + 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", diff --git a/web/styles/chat.scss b/web/styles/chat.scss new file mode 100644 index 000000000..57d1b9cc3 --- /dev/null +++ b/web/styles/chat.scss @@ -0,0 +1,83 @@ +.chat-messages { + .ant-table-small .ant-table-selection-column { + width: 20px; + min-width: 20px; + } + .ant-table-tbody > tr > td { + transition: background 0.15s; + } + .ant-table-row.hidden { + .ant-table-cell { + color: #444450; + } + + } + .ant-table-cell { + font-size: 12px; + + &.name-col { + text-overflow: ellipsis; + overflow: hidden; + } + &.toggle-col { + label { + font-size: 11px; + } + } + + .message-contents { + overflow: auto; + max-height: 200px; + img { + position: relative; + margin-top: -5px; + width: 3rem; + padding: 0.25rem; + } + } + } + + .bulk-editor { + margin: .5rem 0; + padding: .5rem; + border: 1px solid #333; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + border-radius: 4px; + + &.active { + .label { + color: #ccc; + } + } + + .label { + font-size: .75rem; + color: #666; + margin-right: .5rem; + } + + button { + margin: 0 .2rem; + font-size: .75rem; + } + + } +} +.ant-table-filter-dropdown { + max-width: 250px; +} + +.toggle-switch { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + justify-content: flex-end; + + .outcome-icon { + margin-right: .5rem; + } +} diff --git a/web/styles/colors.scss b/web/styles/colors.scss index 22aa1d407..5c93e506d 100644 --- a/web/styles/colors.scss +++ b/web/styles/colors.scss @@ -3,4 +3,10 @@ --owncast-purple-highlight: #ccd; --online-color: #73dd3f; + + + --ant-error: #ff4d4f; + --ant-success: #52c41a; + --ant-warning: #faad14; + } diff --git a/web/styles/globals.scss b/web/styles/globals.scss index a1278937a..49e7e41d8 100644 --- a/web/styles/globals.scss +++ b/web/styles/globals.scss @@ -41,4 +41,4 @@ pre { background-color: rgb(44, 44, 44); color:lightgrey; } -} \ No newline at end of file +} diff --git a/web/types/chat.ts b/web/types/chat.ts new file mode 100644 index 000000000..096839b86 --- /dev/null +++ b/web/types/chat.ts @@ -0,0 +1,10 @@ +export interface MessageType { + author: string; + body: string; + id: string; + key: string; + name: string; + timestamp: string; + type: string; + visible: boolean; +} diff --git a/web/utils/apis.ts b/web/utils/apis.ts index a480a5493..0c60790c1 100644 --- a/web/utils/apis.ts +++ b/web/utils/apis.ts @@ -34,22 +34,47 @@ export const LOGS_ALL = `${API_LOCATION}logs`; // Get warnings + errors export const LOGS_WARN = `${API_LOCATION}logs/warnings`; +// Get chat history +export const CHAT_HISTORY = `${API_LOCATION}chat/messages`; + +// Get chat history +export const UPDATE_CHAT_MESSGAE_VIZ = `${NEXT_PUBLIC_API_HOST}api/admin/chat/updatemessagevisibility`; + + const GITHUB_RELEASE_URL = "https://api.github.com/repos/owncast/owncast/releases/latest"; -export async function fetchData(url) { - let options: RequestInit = {}; +interface FetchOptions { + data?: any; + method?: string; + auth?: boolean; +}; - if (ADMIN_USERNAME && ADMIN_STREAMKEY) { +export async function fetchData(url: string, options?: FetchOptions) { + const { + data, + method = 'GET', + auth = true, + } = options || {}; + + const requestOptions: RequestInit = { + method, + }; + + if (data) { + requestOptions.body = JSON.stringify(data) + } + + if (auth && ADMIN_USERNAME && ADMIN_STREAMKEY) { const encoded = btoa(`${ADMIN_USERNAME}:${ADMIN_STREAMKEY}`); - options.headers = { + requestOptions.headers = { 'Authorization': `Basic ${encoded}` } - options.mode = 'cors'; - options.credentials = 'include' + requestOptions.mode = 'cors'; + requestOptions.credentials = 'include'; } try { - const response = await fetch(url, options); + const response = await fetch(url, requestOptions); if (!response.ok) { const message = `An error has occured: ${response.status}`; throw new Error(message); diff --git a/web/utils/format.ts b/web/utils/format.ts index a34ce1773..c7db93cb0 100644 --- a/web/utils/format.ts +++ b/web/utils/format.ts @@ -15,7 +15,11 @@ export function formatIPAddress(ipAddress: string): string { // check if obj is {} export function isEmptyObject(obj) { - return !obj || Object.keys(obj).length === 0; + return !obj || (Object.keys(obj).length === 0 && obj.constructor === Object); +} + +export function padLeft(text, pad, size) { + return String(pad.repeat(size) + text).slice(-size); } export function parseSecondsToDurationString(seconds = 0) { @@ -35,7 +39,3 @@ export function parseSecondsToDurationString(seconds = 0) { return daysString + hoursString + minString + secsString; } - -export function padLeft(text, pad, size) { - return String(pad.repeat(size) + text).slice(-size); -} \ No newline at end of file