Admin social features (#408)
* ActivityPub admin pages for configuration * Fix dev build * Add support for requiring follow approval. Closes https://github.com/owncast/owncast/issues/1208 * Point at admin version of followers endpoint * Add setting for toggling displaying fediverse engagement in admin. https://github.com/owncast/owncast/issues/1404 * Add instance URL textfield to federation config and disable federation if it is empty * If instance URL is not https disable federation * Tweak federation toggle text. Make go live message optional * Add federation info modal. Closes https://github.com/owncast/owncast/issues/1544 * Add support for blocked federated domains. For https://github.com/owncast/owncast/issues/1209 * Simplify fediverse post input * Add placeholder Fediverse icon * Tweak federation logo in admin menu. Closes https://github.com/owncast/owncast/issues/1603 * Add global button for composing a fediverse post. Closes https://github.com/owncast/owncast/issues/1610 * Federation -> Social * Add page for listing federated actions. Closes https://github.com/owncast/owncast/issues/1573 * Auto-close social post modal after success * Make user modal action buttons look nicer * Center and reduce width and center count column. Closes https://github.com/owncast/owncast/issues/1580 * Update the followers table to be clearer * Fix exception thrown when passing undefined * Disable federation settings if feature is disabled * Update enable social modal. For https://github.com/owncast/owncast/issues/1594 * Fix type props * Quiet, linter * Move compose button to the left * Add tooltip for compose button * Add NSFW toggle to federation config. Closes https://github.com/owncast/owncast/issues/1628 * Add support for blocking/removing followers. For https://github.com/owncast/owncast/issues/1630 * Allow editing the server url field even when federation is disabled * Continue to update the copy around the social features * Use relative path to action images. Fixes https://github.com/owncast/owncast/issues/1646 * Link IRIs and make action verbse present tense * Update caniuse
This commit is contained in:
parent
53d60f5127
commit
084a01fb02
@ -30,8 +30,10 @@ export default function ClientTable({ data }: ClientTableProps) {
|
|||||||
dataIndex: 'messageCount',
|
dataIndex: 'messageCount',
|
||||||
key: 'messageCount',
|
key: 'messageCount',
|
||||||
className: 'number-col',
|
className: 'number-col',
|
||||||
|
width: '12%',
|
||||||
sorter: (a: any, b: any) => a.messageCount - b.messageCount,
|
sorter: (a: any, b: any) => a.messageCount - b.messageCount,
|
||||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||||
|
render: (count: number) => <div style={{ textAlign: 'center' }}>{count}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Connected Time',
|
title: 'Connected Time',
|
||||||
|
76
web/components/compose-federated-post.tsx
Normal file
76
web/components/compose-federated-post.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, Space, Input, Modal } from 'antd';
|
||||||
|
import { STATUS_ERROR, STATUS_SUCCESS } from '../utils/input-statuses';
|
||||||
|
import { fetchData, FEDERATION_MESSAGE_SEND } from '../utils/apis';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface ComposeFederatedPostProps {
|
||||||
|
visible: boolean;
|
||||||
|
handleClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComposeFederatedPost({ visible, handleClose }: ComposeFederatedPostProps) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [postPending, setPostPending] = useState(false);
|
||||||
|
const [postSuccessState, setPostSuccessState] = useState(null);
|
||||||
|
|
||||||
|
function handleEditorChange(e) {
|
||||||
|
setContent(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendButtonClicked() {
|
||||||
|
setPostPending(true);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
value: content,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fetchData(FEDERATION_MESSAGE_SEND, {
|
||||||
|
data,
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
});
|
||||||
|
setPostSuccessState(STATUS_SUCCESS);
|
||||||
|
setTimeout(handleClose, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
setPostSuccessState(STATUS_ERROR);
|
||||||
|
}
|
||||||
|
setPostPending(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
destroyOnClose
|
||||||
|
width={600}
|
||||||
|
title="Post to Followers"
|
||||||
|
visible={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={[
|
||||||
|
<Button onClick={() => handleClose()}>Cancel</Button>,
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={sendButtonClicked}
|
||||||
|
disabled={postPending || postSuccessState}
|
||||||
|
loading={postPending}
|
||||||
|
>
|
||||||
|
{postSuccessState?.toUpperCase() || 'Post'}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Space id="fediverse-post-container" direction="vertical">
|
||||||
|
<TextArea
|
||||||
|
placeholder="Tell the world about your streaming plans..."
|
||||||
|
size="large"
|
||||||
|
showCount
|
||||||
|
maxLength={500}
|
||||||
|
style={{ height: '150px' }}
|
||||||
|
onChange={handleEditorChange}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
@ -55,7 +55,7 @@ export default function EditValueArray(props: EditStringArrayProps) {
|
|||||||
<p className="description">{description}</p>
|
<p className="description">{description}</p>
|
||||||
|
|
||||||
<div className="edit-current-strings">
|
<div className="edit-current-strings">
|
||||||
{values.map((tag, index) => {
|
{values?.map((tag, index) => {
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
handleDeleteIndex(index);
|
handleDeleteIndex(index);
|
||||||
};
|
};
|
||||||
|
@ -4,8 +4,7 @@ import Link from 'next/link';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { differenceInSeconds } from 'date-fns';
|
import { differenceInSeconds } from 'date-fns';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Layout, Menu, Popover, Alert, Typography } from 'antd';
|
import { Layout, Menu, Popover, Alert, Typography, Button, Space, Tooltip } from 'antd';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
@ -16,6 +15,7 @@ import {
|
|||||||
QuestionCircleOutlined,
|
QuestionCircleOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
|
EditOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { upgradeVersionAvailable } from '../utils/apis';
|
import { upgradeVersionAvailable } from '../utils/apis';
|
||||||
@ -27,7 +27,7 @@ import { AlertMessageContext } from '../utils/alert-message-context';
|
|||||||
|
|
||||||
import TextFieldWithSubmit from './config/form-textfield-with-submit';
|
import TextFieldWithSubmit from './config/form-textfield-with-submit';
|
||||||
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
|
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
|
||||||
|
import ComposeFederatedPost from './compose-federated-post';
|
||||||
import { UpdateArgs } from '../types/config-section';
|
import { UpdateArgs } from '../types/config-section';
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line react/function-component-definition
|
||||||
@ -36,9 +36,11 @@ export default function MainLayout(props) {
|
|||||||
|
|
||||||
const context = useContext(ServerStatusContext);
|
const context = useContext(ServerStatusContext);
|
||||||
const { serverConfig, online, broadcaster, versionNumber } = context || {};
|
const { serverConfig, online, broadcaster, versionNumber } = context || {};
|
||||||
const { instanceDetails, chatDisabled } = serverConfig;
|
const { instanceDetails, chatDisabled, federation } = serverConfig;
|
||||||
|
const { enabled: federationEnabled } = federation;
|
||||||
|
|
||||||
const [currentStreamTitle, setCurrentStreamTitle] = useState('');
|
const [currentStreamTitle, setCurrentStreamTitle] = useState('');
|
||||||
|
const [postModalDisplayed, setPostModalDisplayed] = useState(false);
|
||||||
|
|
||||||
const alertMessage = useContext(AlertMessageContext);
|
const alertMessage = useContext(AlertMessageContext);
|
||||||
|
|
||||||
@ -70,6 +72,10 @@ export default function MainLayout(props) {
|
|||||||
setCurrentStreamTitle(value);
|
setCurrentStreamTitle(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreatePostButtonPressed = () => {
|
||||||
|
setPostModalDisplayed(true);
|
||||||
|
};
|
||||||
|
|
||||||
const appClass = classNames({
|
const appClass = classNames({
|
||||||
'app-container': true,
|
'app-container': true,
|
||||||
online,
|
online,
|
||||||
@ -94,12 +100,7 @@ export default function MainLayout(props) {
|
|||||||
? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time)))
|
? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time)))
|
||||||
: '';
|
: '';
|
||||||
const currentThumbnail = online ? (
|
const currentThumbnail = online ? (
|
||||||
<img
|
<img src="/thumbnail.jpg" className="online-thumbnail" alt="current thumbnail" width="1rem" />
|
||||||
src="/thumbnail.jpg"
|
|
||||||
className="online-thumbnail"
|
|
||||||
alt="current thumbnail"
|
|
||||||
style={{ width: '10rem' }}
|
|
||||||
/>
|
|
||||||
) : null;
|
) : null;
|
||||||
const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
|
const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
|
||||||
const statusMessage = online ? `Online ${streamDurationString}` : 'Offline';
|
const statusMessage = online ? `Online ${streamDurationString}` : 'Offline';
|
||||||
@ -162,6 +163,22 @@ export default function MainLayout(props) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</SubMenu>
|
</SubMenu>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
style={{ display: federationEnabled ? 'block' : 'none' }}
|
||||||
|
key="federation-followers"
|
||||||
|
title="Fediverse followers"
|
||||||
|
icon={
|
||||||
|
<img
|
||||||
|
alt="fediverse icon"
|
||||||
|
src="/admin/fediverse-white.png"
|
||||||
|
width="15rem"
|
||||||
|
style={{ opacity: 0.6, position: 'relative', top: '-1px' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link href="/federation/followers">Followers</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<SubMenu key="configuration" title="Configuration" icon={<SettingOutlined />}>
|
<SubMenu key="configuration" title="Configuration" icon={<SettingOutlined />}>
|
||||||
<Menu.Item key="config-public-details">
|
<Menu.Item key="config-public-details">
|
||||||
<Link href="/config-public-details">General</Link>
|
<Link href="/config-public-details">General</Link>
|
||||||
@ -171,11 +188,15 @@ export default function MainLayout(props) {
|
|||||||
<Link href="/config-server-details">Server Setup</Link>
|
<Link href="/config-server-details">Server Setup</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item key="config-video">
|
<Menu.Item key="config-video">
|
||||||
<Link href="/config-video">Video Configuration</Link>
|
<Link href="/config-video">Video</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item key="config-chat">
|
<Menu.Item key="config-chat">
|
||||||
<Link href="/config-chat">Chat</Link>
|
<Link href="/config-chat">Chat</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="config-federation">
|
||||||
|
<Link href="/config-federation">Social</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item key="config-storage">
|
<Menu.Item key="config-storage">
|
||||||
<Link href="/config-storage">S3 Storage</Link>
|
<Link href="/config-storage">S3 Storage</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@ -188,6 +209,9 @@ export default function MainLayout(props) {
|
|||||||
<Menu.Item key="logs">
|
<Menu.Item key="logs">
|
||||||
<Link href="/logs">Logs</Link>
|
<Link href="/logs">Logs</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="federation-activities" title="Social Actions">
|
||||||
|
<Link href="/federation/actions">Social Actions</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item key="upgrade" style={{ display: upgradeMenuItemStyle }}>
|
<Menu.Item key="upgrade" style={{ display: upgradeMenuItemStyle }}>
|
||||||
<Link href="/upgrade">{upgradeMessage}</Link>
|
<Link href="/upgrade">{upgradeMessage}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@ -211,6 +235,18 @@ export default function MainLayout(props) {
|
|||||||
|
|
||||||
<Layout className="layout-main">
|
<Layout className="layout-main">
|
||||||
<Header className="layout-header">
|
<Header className="layout-header">
|
||||||
|
<Space direction="horizontal">
|
||||||
|
<Tooltip title="Compose post to your followers">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
size="large"
|
||||||
|
onClick={handleCreatePostButtonPressed}
|
||||||
|
style={{ display: federationEnabled ? 'block' : 'none' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
<div className="global-stream-title-container">
|
<div className="global-stream-title-container">
|
||||||
<TextFieldWithSubmit
|
<TextFieldWithSubmit
|
||||||
fieldName="streamTitle"
|
fieldName="streamTitle"
|
||||||
@ -221,8 +257,7 @@ export default function MainLayout(props) {
|
|||||||
onChange={handleStreamTitleChanged}
|
onChange={handleStreamTitleChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Space direction="horizontal">{statusIndicatorWithThumb}</Space>
|
||||||
{statusIndicatorWithThumb}
|
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
{headerAlertMessage}
|
{headerAlertMessage}
|
||||||
@ -235,6 +270,11 @@ export default function MainLayout(props) {
|
|||||||
</a>
|
</a>
|
||||||
</Footer>
|
</Footer>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<ComposeFederatedPost
|
||||||
|
visible={postModalDisplayed}
|
||||||
|
handleClose={() => setPostModalDisplayed(false)}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { Modal, Button } from 'antd';
|
import { Modal, Button } from 'antd';
|
||||||
import { ExclamationCircleFilled, QuestionCircleFilled, StopTwoTone } from '@ant-design/icons';
|
import {
|
||||||
|
ExclamationCircleFilled,
|
||||||
|
QuestionCircleFilled,
|
||||||
|
StopTwoTone,
|
||||||
|
SafetyCertificateTwoTone,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { USER_SET_MODERATOR, fetchData } from '../utils/apis';
|
import { USER_SET_MODERATOR, fetchData } from '../utils/apis';
|
||||||
import { User } from '../types/chat';
|
import { User } from '../types/chat';
|
||||||
|
|
||||||
@ -70,7 +75,13 @@ export default function ModeratorUserButton({ user, onClick }: ModeratorUserButt
|
|||||||
<Button
|
<Button
|
||||||
onClick={confirmBlockAction}
|
onClick={confirmBlockAction}
|
||||||
size="small"
|
size="small"
|
||||||
icon={isModerator ? <StopTwoTone twoToneColor="#ff4d4f" /> : null}
|
icon={
|
||||||
|
isModerator ? (
|
||||||
|
<StopTwoTone twoToneColor="#ff4d4f" />
|
||||||
|
) : (
|
||||||
|
<SafetyCertificateTwoTone twoToneColor="#22bb44" />
|
||||||
|
)
|
||||||
|
}
|
||||||
className="block-user-button"
|
className="block-user-button"
|
||||||
>
|
>
|
||||||
{actionString}
|
{actionString}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// This displays a clickable user name (or whatever children element you provide), and displays a simple tooltip of created time. OnClick a modal with more information about the user is displayed.
|
// This displays a clickable user name (or whatever children element you provide), and displays a simple tooltip of created time. OnClick a modal with more information about the user is displayed.
|
||||||
|
|
||||||
import { useState, ReactNode } from 'react';
|
import { useState, ReactNode } from 'react';
|
||||||
import { Divider, Modal, Tooltip, Typography, Row, Col } from 'antd';
|
import { Divider, Modal, Tooltip, Typography, Row, Col, Space } from 'antd';
|
||||||
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
@ -117,27 +117,29 @@ export default function UserPopover({ user, connectionInfo, children }: UserPopo
|
|||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<Divider />
|
<Divider />
|
||||||
{disabledAt ? (
|
<Space direction="horizontal">
|
||||||
<>
|
{disabledAt ? (
|
||||||
This user was banned on <code>{formatDisplayDate(disabledAt)}</code>.
|
<>
|
||||||
<br />
|
This user was banned on <code>{formatDisplayDate(disabledAt)}</code>.
|
||||||
<br />
|
<br />
|
||||||
|
<br />
|
||||||
|
<BlockUserbutton
|
||||||
|
label="Unban this user"
|
||||||
|
user={user}
|
||||||
|
isEnabled={false}
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<BlockUserbutton
|
<BlockUserbutton
|
||||||
label="Unban this user"
|
label="Ban this user"
|
||||||
user={user}
|
user={user}
|
||||||
isEnabled={false}
|
isEnabled
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
) : (
|
<ModeratorUserbutton user={user} onClick={handleCloseModal} />
|
||||||
<BlockUserbutton
|
</Space>
|
||||||
label="Ban this user"
|
|
||||||
user={user}
|
|
||||||
isEnabled
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ModeratorUserbutton user={user} onClick={handleCloseModal} />
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
1
web/next-env.d.ts
vendored
1
web/next-env.d.ts
vendored
@ -1,5 +1,4 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/types/global" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
|
16
web/package-lock.json
generated
16
web/package-lock.json
generated
@ -1387,9 +1387,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001243",
|
"version": "1.0.30001297",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz",
|
||||||
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA=="
|
"integrity": "sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
@ -7663,9 +7667,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"caniuse-lite": {
|
"caniuse-lite": {
|
||||||
"version": "1.0.30001243",
|
"version": "1.0.30001297",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz",
|
||||||
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA=="
|
"integrity": "sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA=="
|
||||||
},
|
},
|
||||||
"chalk": {
|
"chalk": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
|
323
web/pages/config-federation.tsx
Normal file
323
web/pages/config-federation.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
/* eslint-disable react/no-unescaped-entities */
|
||||||
|
import { Typography, Modal, Button, Row, Col } from 'antd';
|
||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {
|
||||||
|
TEXTFIELD_TYPE_TEXT,
|
||||||
|
TEXTFIELD_TYPE_TEXTAREA,
|
||||||
|
TEXTFIELD_TYPE_URL,
|
||||||
|
} from '../components/config/form-textfield';
|
||||||
|
import TextFieldWithSubmit from '../components/config/form-textfield-with-submit';
|
||||||
|
import ToggleSwitch from '../components/config/form-toggleswitch';
|
||||||
|
import EditValueArray from '../components/config/edit-string-array';
|
||||||
|
import { UpdateArgs } from '../types/config-section';
|
||||||
|
import {
|
||||||
|
FIELD_PROPS_ENABLE_FEDERATION,
|
||||||
|
TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE,
|
||||||
|
TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER,
|
||||||
|
FIELD_PROPS_FEDERATION_IS_PRIVATE,
|
||||||
|
FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT,
|
||||||
|
TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL,
|
||||||
|
FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS,
|
||||||
|
postConfigUpdateToAPI,
|
||||||
|
RESET_TIMEOUT,
|
||||||
|
API_FEDERATION_BLOCKED_DOMAINS,
|
||||||
|
FIELD_PROPS_FEDERATION_NSFW,
|
||||||
|
} from '../utils/config-constants';
|
||||||
|
import { ServerStatusContext } from '../utils/server-status-context';
|
||||||
|
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../utils/input-statuses';
|
||||||
|
|
||||||
|
function FederationInfoModal({ cancelPressed, okPressed }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
width="70%"
|
||||||
|
title="Enable Social Features"
|
||||||
|
visible
|
||||||
|
onCancel={cancelPressed}
|
||||||
|
footer={
|
||||||
|
<div>
|
||||||
|
<Button onClick={cancelPressed}>Do not enable</Button>
|
||||||
|
<Button type="primary" onClick={okPressed}>
|
||||||
|
Enable Social Features
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Title level={3}>How do Owncast's social features work?</Typography.Title>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
Owncast's social features are accomplished by having your server join The{' '}
|
||||||
|
<a href="https://en.wikipedia.org/wiki/Fediverse" rel="noopener noreferrer" target="_blank">
|
||||||
|
Fediverse
|
||||||
|
</a>
|
||||||
|
, a decentralized, open, collection of independent servers, like yours.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
Please{' '}
|
||||||
|
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
|
||||||
|
read more
|
||||||
|
</a>{' '}
|
||||||
|
about these features, the details behind them, and how they work.
|
||||||
|
<Typography.Paragraph />
|
||||||
|
<Typography.Title level={3}>What do you need to know?</Typography.Title>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
These features are brand new. Given the variability of interfacing with the rest of the
|
||||||
|
world, bugs are possible. Please report anything that you think isn't working quite right.
|
||||||
|
</li>
|
||||||
|
<li>You must always host your Owncast server with SSL using a https url.</li>
|
||||||
|
<li>
|
||||||
|
You should not change your server name URL or social username once people begin following
|
||||||
|
you, as you will be seen as a completely different user on the Fediverse, and the old user
|
||||||
|
will disappear.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Turning on <i>Private mode</i> will allow you to manually approve each follower and limit
|
||||||
|
the visibility of your posts to followers only.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Typography.Title level={3}>Learn more about The Fediverse</Typography.Title>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
If these concepts are new you should discover more about what this functionality has to
|
||||||
|
offer. Visit{' '}
|
||||||
|
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
|
||||||
|
our documentation
|
||||||
|
</a>{' '}
|
||||||
|
to be pointed at some resources that will help get you started on The Fediverse.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FederationInfoModal.propTypes = {
|
||||||
|
cancelPressed: PropTypes.func.isRequired,
|
||||||
|
okPressed: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfigFederation() {
|
||||||
|
const { Title } = Typography;
|
||||||
|
const [formDataValues, setFormDataValues] = useState(null);
|
||||||
|
const [isInfoModalOpen, setIsInfoModalOpen] = useState(false);
|
||||||
|
const serverStatusData = useContext(ServerStatusContext);
|
||||||
|
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||||
|
const [blockedDomainSaveState, setBlockedDomainSaveState] = useState(null);
|
||||||
|
|
||||||
|
const { federation, yp, instanceDetails } = serverConfig;
|
||||||
|
const { enabled, isPrivate, username, goLiveMessage, showEngagement, blockedDomains } =
|
||||||
|
federation;
|
||||||
|
const { instanceUrl } = yp;
|
||||||
|
const { nsfw } = instanceDetails;
|
||||||
|
|
||||||
|
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||||
|
setFormDataValues({
|
||||||
|
...formDataValues,
|
||||||
|
[fieldName]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnabledSwitchChange = (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
|
setFormDataValues({
|
||||||
|
...formDataValues,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsInfoModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
|
||||||
|
const handleSubmitInstanceUrl = () => {
|
||||||
|
const hasInstanceUrl = formDataValues.instanceUrl !== '';
|
||||||
|
const isInstanceUrlSecure = formDataValues.instanceUrl.startsWith('https://');
|
||||||
|
|
||||||
|
if (!hasInstanceUrl || !isInstanceUrlSecure) {
|
||||||
|
postConfigUpdateToAPI({
|
||||||
|
apiPath: FIELD_PROPS_ENABLE_FEDERATION.apiPath,
|
||||||
|
data: { value: false },
|
||||||
|
});
|
||||||
|
setFormDataValues({
|
||||||
|
...formDataValues,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function federationInfoModalCancelPressed() {
|
||||||
|
setIsInfoModalOpen(false);
|
||||||
|
setFormDataValues({
|
||||||
|
...formDataValues,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function federationInfoModalOkPressed() {
|
||||||
|
setIsInfoModalOpen(false);
|
||||||
|
setFormDataValues({
|
||||||
|
...formDataValues,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBlockedDomainsSaveState() {
|
||||||
|
setBlockedDomainSaveState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlockedDomains() {
|
||||||
|
try {
|
||||||
|
postConfigUpdateToAPI({
|
||||||
|
apiPath: API_FEDERATION_BLOCKED_DOMAINS,
|
||||||
|
data: { value: formDataValues.blockedDomains },
|
||||||
|
onSuccess: () => {
|
||||||
|
setFieldInConfigState({
|
||||||
|
fieldName: 'forbiddenUsernames',
|
||||||
|
value: formDataValues.forbiddenUsernames,
|
||||||
|
});
|
||||||
|
setBlockedDomainSaveState(STATUS_SUCCESS);
|
||||||
|
setTimeout(resetBlockedDomainsSaveState, RESET_TIMEOUT);
|
||||||
|
},
|
||||||
|
onError: (message: string) => {
|
||||||
|
setBlockedDomainSaveState(createInputStatus(STATUS_ERROR, message));
|
||||||
|
setTimeout(resetBlockedDomainsSaveState, RESET_TIMEOUT);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setBlockedDomainSaveState(STATUS_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteBlockedDomain(index: number) {
|
||||||
|
formDataValues.blockedDomains.splice(index, 1);
|
||||||
|
saveBlockedDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateBlockedDomain(domain: string) {
|
||||||
|
let newDomain;
|
||||||
|
try {
|
||||||
|
const u = new URL(domain);
|
||||||
|
newDomain = u.host;
|
||||||
|
} catch (_) {
|
||||||
|
newDomain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
formDataValues.blockedDomains.push(newDomain);
|
||||||
|
handleFieldChange({
|
||||||
|
fieldName: 'blockedDomains',
|
||||||
|
value: formDataValues.blockedDomains,
|
||||||
|
});
|
||||||
|
saveBlockedDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormDataValues({
|
||||||
|
enabled,
|
||||||
|
isPrivate,
|
||||||
|
username,
|
||||||
|
goLiveMessage,
|
||||||
|
showEngagement,
|
||||||
|
blockedDomains,
|
||||||
|
nsfw,
|
||||||
|
instanceUrl: yp.instanceUrl,
|
||||||
|
});
|
||||||
|
}, [serverConfig, yp]);
|
||||||
|
|
||||||
|
if (!formDataValues) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInstanceUrl = instanceUrl !== '';
|
||||||
|
const isInstanceUrlSecure = instanceUrl.startsWith('https://');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title>Configure Social Features</Title>
|
||||||
|
<p>
|
||||||
|
Owncast provides the ability for people to follow and engage with your instance. It's a
|
||||||
|
great way to promote alerting, sharing and engagement of your stream.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Once enabled you'll alert your followers when you go live as well as gain the ability to
|
||||||
|
compose custom posts to share any information you like.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
|
||||||
|
Read more about the specifics of these social features.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<Row>
|
||||||
|
<Col span={15} className="form-module" style={{ marginRight: '15px' }}>
|
||||||
|
<ToggleSwitch
|
||||||
|
fieldName="enabled"
|
||||||
|
onChange={handleEnabledSwitchChange}
|
||||||
|
{...FIELD_PROPS_ENABLE_FEDERATION}
|
||||||
|
checked={formDataValues.enabled}
|
||||||
|
disabled={!hasInstanceUrl || !isInstanceUrlSecure}
|
||||||
|
/>
|
||||||
|
<TextFieldWithSubmit
|
||||||
|
fieldName="instanceUrl"
|
||||||
|
{...TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL}
|
||||||
|
value={formDataValues.instanceUrl}
|
||||||
|
initialValue={yp.instanceUrl}
|
||||||
|
type={TEXTFIELD_TYPE_URL}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
onSubmit={handleSubmitInstanceUrl}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
fieldName="isPrivate"
|
||||||
|
{...FIELD_PROPS_FEDERATION_IS_PRIVATE}
|
||||||
|
checked={formDataValues.isPrivate}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
fieldName="nsfw"
|
||||||
|
useSubmit
|
||||||
|
{...FIELD_PROPS_FEDERATION_NSFW}
|
||||||
|
checked={formDataValues.nsfw}
|
||||||
|
disabled={!hasInstanceUrl}
|
||||||
|
/>
|
||||||
|
<TextFieldWithSubmit
|
||||||
|
required
|
||||||
|
fieldName="username"
|
||||||
|
type={TEXTFIELD_TYPE_TEXT}
|
||||||
|
{...TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER}
|
||||||
|
value={formDataValues.username}
|
||||||
|
initialValue={username}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<TextFieldWithSubmit
|
||||||
|
fieldName="goLiveMessage"
|
||||||
|
{...TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE}
|
||||||
|
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||||
|
value={formDataValues.goLiveMessage}
|
||||||
|
initialValue={goLiveMessage}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
fieldName="showEngagement"
|
||||||
|
{...FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT}
|
||||||
|
checked={formDataValues.showEngagement}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8} className="form-module">
|
||||||
|
<EditValueArray
|
||||||
|
title={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.label}
|
||||||
|
placeholder={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.placeholder}
|
||||||
|
description={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.tip}
|
||||||
|
values={formDataValues.blockedDomains}
|
||||||
|
handleDeleteIndex={handleDeleteBlockedDomain}
|
||||||
|
handleCreateString={handleCreateBlockedDomain}
|
||||||
|
submitStatus={createInputStatus(blockedDomainSaveState)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{isInfoModalOpen && (
|
||||||
|
<FederationInfoModal
|
||||||
|
cancelPressed={federationInfoModalCancelPressed}
|
||||||
|
okPressed={federationInfoModalOkPressed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
120
web/pages/federation/actions.tsx
Normal file
120
web/pages/federation/actions.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Table, Typography } from 'antd';
|
||||||
|
import { ColumnsType } from 'antd/lib/table/interface';
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import { FEDERATION_ACTIONS, fetchData } from '../../utils/apis';
|
||||||
|
|
||||||
|
import { isEmptyObject } from '../../utils/format';
|
||||||
|
|
||||||
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
export interface Action {
|
||||||
|
iri: string;
|
||||||
|
actorIRI: string;
|
||||||
|
type: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FediverseActions() {
|
||||||
|
const [actions, setActions] = useState<Action[]>([]);
|
||||||
|
|
||||||
|
const getActions = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchData(FEDERATION_ACTIONS, { auth: true });
|
||||||
|
if (isEmptyObject(result)) {
|
||||||
|
setActions([]);
|
||||||
|
} else {
|
||||||
|
setActions(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('==== error', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getActions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Action> = [
|
||||||
|
{
|
||||||
|
title: 'Action',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 50,
|
||||||
|
render: (_, record) => {
|
||||||
|
let image;
|
||||||
|
let title;
|
||||||
|
switch (record.type) {
|
||||||
|
case 'FEDIVERSE_ENGAGEMENT_REPOST':
|
||||||
|
image = '/img/repost.svg';
|
||||||
|
title = 'Share';
|
||||||
|
break;
|
||||||
|
case 'FEDIVERSE_ENGAGEMENT_LIKE':
|
||||||
|
image = '/img/like.svg';
|
||||||
|
title = 'Like';
|
||||||
|
break;
|
||||||
|
case 'FEDIVERSE_ENGAGEMENT_FOLLOW':
|
||||||
|
image = '/img/follow.svg';
|
||||||
|
title = 'Follow';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
image = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={image} width="70%" alt={title} title={title} />
|
||||||
|
<div style={{ fontSize: '0.7rem' }}>{title}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'From',
|
||||||
|
dataIndex: 'actorIRI',
|
||||||
|
key: 'from',
|
||||||
|
render: (_, record) => <a href={record.actorIRI}>{record.actorIRI}</a>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'When',
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'timestamp',
|
||||||
|
render: (_, record) => {
|
||||||
|
const dateObject = new Date(record.timestamp);
|
||||||
|
return format(dateObject, 'P pp');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeTable(data: Action[], tableColumns: ColumnsType<Action>) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={data}
|
||||||
|
columns={tableColumns}
|
||||||
|
size="small"
|
||||||
|
rowKey={row => row.iri}
|
||||||
|
pagination={{ pageSize: 50 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={3}>Fediverse Actions</Title>
|
||||||
|
<Paragraph>
|
||||||
|
Below is a list of actions that were taken by others in response to your posts as well as
|
||||||
|
people who requested to follow you.
|
||||||
|
</Paragraph>
|
||||||
|
{makeTable(actions, columns)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
318
web/pages/federation/followers.tsx
Normal file
318
web/pages/federation/followers.tsx
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import React, { useEffect, useState, useContext } from 'react';
|
||||||
|
import { Table, Avatar, Button, Tabs } from 'antd';
|
||||||
|
import { ColumnsType, SortOrder } from 'antd/lib/table/interface';
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import { UserAddOutlined, UserDeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||||
|
import {
|
||||||
|
FOLLOWERS,
|
||||||
|
FOLLOWERS_PENDING,
|
||||||
|
SET_FOLLOWER_APPROVAL,
|
||||||
|
FOLLOWERS_BLOCKED,
|
||||||
|
fetchData,
|
||||||
|
} from '../../utils/apis';
|
||||||
|
import { isEmptyObject } from '../../utils/format';
|
||||||
|
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
export interface Follower {
|
||||||
|
link: string;
|
||||||
|
username: string;
|
||||||
|
image: string;
|
||||||
|
name: string;
|
||||||
|
timestamp: Date;
|
||||||
|
approved: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FediverseFollowers() {
|
||||||
|
const [followersPending, setFollowersPending] = useState<Follower[]>([]);
|
||||||
|
const [followersBlocked, setFollowersBlocked] = useState<Follower[]>([]);
|
||||||
|
const [followers, setFollowers] = useState<Follower[]>([]);
|
||||||
|
|
||||||
|
const serverStatusData = useContext(ServerStatusContext);
|
||||||
|
const { serverConfig } = serverStatusData || {};
|
||||||
|
const { federation } = serverConfig;
|
||||||
|
const { isPrivate } = federation;
|
||||||
|
|
||||||
|
const getFollowers = async () => {
|
||||||
|
try {
|
||||||
|
// Active followers
|
||||||
|
const followersResult = await fetchData(FOLLOWERS, { auth: true });
|
||||||
|
if (isEmptyObject(followersResult)) {
|
||||||
|
setFollowers([]);
|
||||||
|
} else {
|
||||||
|
setFollowers(followersResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending follow requests
|
||||||
|
const pendingFollowersResult = await fetchData(FOLLOWERS_PENDING, { auth: true });
|
||||||
|
if (isEmptyObject(pendingFollowersResult)) {
|
||||||
|
setFollowersPending([]);
|
||||||
|
} else {
|
||||||
|
setFollowersPending(pendingFollowersResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked/rejected followers
|
||||||
|
const blockedFollowersResult = await fetchData(FOLLOWERS_BLOCKED, { auth: true });
|
||||||
|
if (isEmptyObject(followersBlocked)) {
|
||||||
|
setFollowersBlocked([]);
|
||||||
|
} else {
|
||||||
|
setFollowersBlocked(blockedFollowersResult);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('==== error', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFollowers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Follower> = [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'image',
|
||||||
|
key: 'image',
|
||||||
|
width: 90,
|
||||||
|
render: image => <Avatar size={40} src={image || '/img/logo.svg'} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (_, follower) => (
|
||||||
|
<a href={follower.link} target="_blank" rel="noreferrer">
|
||||||
|
{follower.name || follower.username}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'URL',
|
||||||
|
dataIndex: 'link',
|
||||||
|
key: 'link',
|
||||||
|
render: (_, follower) => (
|
||||||
|
<a href={follower.link} target="_blank" rel="noreferrer">
|
||||||
|
{follower.link}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeTable(data: Follower[], tableColumns: ColumnsType<Follower>) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={data}
|
||||||
|
columns={tableColumns}
|
||||||
|
size="small"
|
||||||
|
rowKey={row => row.link}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveFollowRequest(request) {
|
||||||
|
try {
|
||||||
|
await fetchData(SET_FOLLOWER_APPROVAL, {
|
||||||
|
auth: true,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
actorIRI: request.link,
|
||||||
|
approved: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch and update the current data.
|
||||||
|
getFollowers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectFollowRequest(request) {
|
||||||
|
try {
|
||||||
|
await fetchData(SET_FOLLOWER_APPROVAL, {
|
||||||
|
auth: true,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
actorIRI: request.link,
|
||||||
|
approved: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch and update the current data.
|
||||||
|
getFollowers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingColumns: ColumnsType<Follower> = [...columns];
|
||||||
|
pendingColumns.unshift(
|
||||||
|
{
|
||||||
|
title: 'Approve',
|
||||||
|
dataIndex: null,
|
||||||
|
key: null,
|
||||||
|
render: request => (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
approveFollowRequest(request);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Reject',
|
||||||
|
dataIndex: null,
|
||||||
|
key: null,
|
||||||
|
render: request => (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<UserDeleteOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
rejectFollowRequest(request);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: 50,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
pendingColumns.push({
|
||||||
|
title: 'Requested',
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'requested',
|
||||||
|
width: 200,
|
||||||
|
render: timestamp => {
|
||||||
|
const dateObject = new Date(timestamp);
|
||||||
|
return <>{format(dateObject, 'P')}</>;
|
||||||
|
},
|
||||||
|
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
|
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||||
|
defaultSortOrder: 'descend' as SortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockedColumns: ColumnsType<Follower> = [...columns];
|
||||||
|
blockedColumns.unshift({
|
||||||
|
title: 'Approve',
|
||||||
|
dataIndex: null,
|
||||||
|
key: null,
|
||||||
|
render: request => (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
size="large"
|
||||||
|
onClick={() => {
|
||||||
|
approveFollowRequest(request);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
blockedColumns.push(
|
||||||
|
{
|
||||||
|
title: 'Requested',
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'requested',
|
||||||
|
width: 200,
|
||||||
|
render: timestamp => {
|
||||||
|
const dateObject = new Date(timestamp);
|
||||||
|
return <>{format(dateObject, 'P')}</>;
|
||||||
|
},
|
||||||
|
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
|
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||||
|
defaultSortOrder: 'descend' as SortOrder,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rejected/Blocked',
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'disabled_at',
|
||||||
|
width: 200,
|
||||||
|
render: timestamp => {
|
||||||
|
const dateObject = new Date(timestamp);
|
||||||
|
return <>{format(dateObject, 'P')}</>;
|
||||||
|
},
|
||||||
|
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
|
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||||
|
defaultSortOrder: 'descend' as SortOrder,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const followersColumns: ColumnsType<Follower> = [...columns];
|
||||||
|
|
||||||
|
followersColumns.push(
|
||||||
|
{
|
||||||
|
title: 'Added',
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'timestamp',
|
||||||
|
width: 200,
|
||||||
|
render: timestamp => {
|
||||||
|
const dateObject = new Date(timestamp);
|
||||||
|
return <>{format(dateObject, 'P')}</>;
|
||||||
|
},
|
||||||
|
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
|
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||||
|
defaultSortOrder: 'descend' as SortOrder,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Remove',
|
||||||
|
dataIndex: null,
|
||||||
|
key: null,
|
||||||
|
render: request => (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<UserDeleteOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
rejectFollowRequest(request);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: 50,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingRequestsTab = isPrivate && (
|
||||||
|
<TabPane
|
||||||
|
tab={<span>Requests {followersPending.length > 0 && `(${followersPending.length})`}</span>}
|
||||||
|
key="2"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
The following people are requesting to follow your Owncast server on the{' '}
|
||||||
|
<a href="https://en.wikipedia.org/wiki/Fediverse" target="_blank" rel="noopener noreferrer">
|
||||||
|
Fediverse
|
||||||
|
</a>{' '}
|
||||||
|
and be alerted to when you go live. Each must be approved.
|
||||||
|
</p>
|
||||||
|
{makeTable(followersPending, pendingColumns)}
|
||||||
|
</TabPane>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="followers-section">
|
||||||
|
<Tabs defaultActiveKey="1">
|
||||||
|
<TabPane
|
||||||
|
tab={<span>Followers {followers.length > 0 && `(${followers.length})`}</span>}
|
||||||
|
key="1"
|
||||||
|
>
|
||||||
|
<p>The following accounts get notified when you go live or send a post.</p>
|
||||||
|
{makeTable(followers, followersColumns)}{' '}
|
||||||
|
</TabPane>
|
||||||
|
{pendingRequestsTab}
|
||||||
|
<TabPane
|
||||||
|
tab={<span>Blocked {followersBlocked.length > 0 && `(${followersBlocked.length})`}</span>}
|
||||||
|
key="3"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
The following people were either rejected or blocked by you. You can approve them as a
|
||||||
|
follower.
|
||||||
|
</p>
|
||||||
|
<p>{makeTable(followersBlocked, blockedColumns)}</p>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
BIN
web/public/fediverse-white.png
Normal file
BIN
web/public/fediverse-white.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
@ -32,7 +32,7 @@ p,
|
|||||||
p.description,
|
p.description,
|
||||||
.description,
|
.description,
|
||||||
.ant-typography {
|
.ant-typography {
|
||||||
font-weight: 300;
|
font-weight: 500;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
color: var(--white-75);
|
color: var(--white-75);
|
||||||
}
|
}
|
||||||
@ -158,3 +158,19 @@ input {
|
|||||||
.ant-tabs-ink-bar {
|
.ant-tabs-ink-bar {
|
||||||
background: var(--nav-selected-text);
|
background: var(--nav-selected-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#fediverse-post-container {
|
||||||
|
max-width: 50vw;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
#fediverse-post-input {
|
||||||
|
color: var(--white-75);
|
||||||
|
border-color: var(--owncast-purple);
|
||||||
|
background-color: var(--default-bg-color);
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-textarea-show-count::after{
|
||||||
|
color: var(--owncast-purple);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -44,6 +44,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
background-color: var(--nav-bg-color);
|
background-color: var(--nav-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,6 +89,15 @@ export interface ExternalAction {
|
|||||||
openExternally: boolean;
|
openExternally: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Federation {
|
||||||
|
enabled: boolean;
|
||||||
|
isPrivate: boolean;
|
||||||
|
username: string;
|
||||||
|
goLiveMessage: string;
|
||||||
|
showEngagement: boolean;
|
||||||
|
blockedDomains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigDetails {
|
export interface ConfigDetails {
|
||||||
externalActions: ExternalAction[];
|
externalActions: ExternalAction[];
|
||||||
ffmpegPath: string;
|
ffmpegPath: string;
|
||||||
@ -104,4 +113,5 @@ export interface ConfigDetails {
|
|||||||
forbiddenUsernames: string[];
|
forbiddenUsernames: string[];
|
||||||
suggestedUsernames: string[];
|
suggestedUsernames: string[];
|
||||||
chatDisabled: boolean;
|
chatDisabled: boolean;
|
||||||
|
federation: Federation;
|
||||||
}
|
}
|
||||||
|
@ -79,6 +79,24 @@ export const SOCIAL_PLATFORMS_LIST = `${NEXT_PUBLIC_API_HOST}api/socialplatforms
|
|||||||
// set external action links
|
// set external action links
|
||||||
export const EXTERNAL_ACTIONS = `${API_LOCATION}api/externalactions`;
|
export const EXTERNAL_ACTIONS = `${API_LOCATION}api/externalactions`;
|
||||||
|
|
||||||
|
// send a message to the fediverse
|
||||||
|
export const FEDERATION_MESSAGE_SEND = `${API_LOCATION}federation/send`;
|
||||||
|
|
||||||
|
// Get followers
|
||||||
|
export const FOLLOWERS = `${API_LOCATION}followers`;
|
||||||
|
|
||||||
|
// Get followers pending approval
|
||||||
|
export const FOLLOWERS_PENDING = `${API_LOCATION}followers/pending`;
|
||||||
|
|
||||||
|
// Get followers who were blocked or rejected
|
||||||
|
export const FOLLOWERS_BLOCKED = `${API_LOCATION}followers/blocked`;
|
||||||
|
|
||||||
|
// Approve, reject a follow request
|
||||||
|
export const SET_FOLLOWER_APPROVAL = `${API_LOCATION}followers/approve`;
|
||||||
|
|
||||||
|
// List of inbound federated actions that took place.
|
||||||
|
export const FEDERATION_ACTIONS = `${API_LOCATION}federation/actions`;
|
||||||
|
|
||||||
export const API_YP_RESET = `${API_LOCATION}yp/reset`;
|
export const API_YP_RESET = `${API_LOCATION}yp/reset`;
|
||||||
|
|
||||||
export const TEMP_UPDATER_API = LOGS_ALL;
|
export const TEMP_UPDATER_API = LOGS_ALL;
|
||||||
|
@ -35,6 +35,14 @@ export const API_CHAT_SUGGESTED_USERNAMES = '/chat/suggestedusernames';
|
|||||||
export const API_EXTERNAL_ACTIONS = '/externalactions';
|
export const API_EXTERNAL_ACTIONS = '/externalactions';
|
||||||
export const API_VIDEO_CODEC = '/video/codec';
|
export const API_VIDEO_CODEC = '/video/codec';
|
||||||
|
|
||||||
|
// Federation
|
||||||
|
export const API_FEDERATION_ENABLED = '/federation/enable';
|
||||||
|
export const API_FEDERATION_PRIVATE = '/federation/private';
|
||||||
|
export const API_FEDERATION_USERNAME = '/federation/username';
|
||||||
|
export const API_FEDERATION_GOLIVE_MESSAGE = '/federation/livemessage';
|
||||||
|
export const API_FEDERATION_SHOW_ENGAGEMENT = '/federation/showengagement';
|
||||||
|
export const API_FEDERATION_BLOCKED_DOMAINS = '/federation/blockdomains';
|
||||||
|
|
||||||
export async function postConfigUpdateToAPI(args: ApiPostArgs) {
|
export async function postConfigUpdateToAPI(args: ApiPostArgs) {
|
||||||
const { apiPath, data, onSuccess, onError } = args;
|
const { apiPath, data, onSuccess, onError } = args;
|
||||||
const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, {
|
const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, {
|
||||||
@ -200,6 +208,76 @@ export const TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES = {
|
|||||||
no_entries: 'The default name generator is used.',
|
no_entries: 'The default name generator is used.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FIELD_PROPS_ENABLE_FEDERATION = {
|
||||||
|
apiPath: API_FEDERATION_ENABLED,
|
||||||
|
configPath: 'federation',
|
||||||
|
label: 'Enable Social Features',
|
||||||
|
tip: 'Send and receive activities on the Fediverse.',
|
||||||
|
useSubmit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_PROPS_FEDERATION_IS_PRIVATE = {
|
||||||
|
apiPath: API_FEDERATION_PRIVATE,
|
||||||
|
configPath: 'federation',
|
||||||
|
label: 'Private',
|
||||||
|
tip: 'Follow requests will require approval and only followers will see your activity.',
|
||||||
|
useSubmit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT = {
|
||||||
|
apiPath: API_FEDERATION_SHOW_ENGAGEMENT,
|
||||||
|
configPath: 'showEngagement',
|
||||||
|
label: 'Show engagement',
|
||||||
|
tip: 'Following, liking and sharing will appear in the chat feed.',
|
||||||
|
useSubmit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE = {
|
||||||
|
apiPath: API_FEDERATION_GOLIVE_MESSAGE,
|
||||||
|
configPath: 'federation',
|
||||||
|
maxLength: 500,
|
||||||
|
placeholder: 'My stream has started, tune in!',
|
||||||
|
label: 'Now Live message',
|
||||||
|
tip: 'The message sent announcing that your live stream has begun. Tags will be automatically added. Leave blank to disable.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER = {
|
||||||
|
apiPath: API_FEDERATION_USERNAME,
|
||||||
|
configPath: 'federation',
|
||||||
|
maxLength: 10,
|
||||||
|
placeholder: 'owncast',
|
||||||
|
default: 'owncast',
|
||||||
|
label: 'Username',
|
||||||
|
tip: 'The username used for sending and receiving activities from the Fediverse. For example, if you use "bob" as a username you would send messages to the fediverse from @bob@yourserver. Once people start following your instance you should not change this.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL = {
|
||||||
|
apiPath: API_INSTANCE_URL,
|
||||||
|
configPath: 'yp',
|
||||||
|
maxLength: 255,
|
||||||
|
placeholder: 'https://owncast.mysite.com',
|
||||||
|
label: 'Server URL',
|
||||||
|
tip: 'The full url to your Owncast server is required to enable social features. Must use SSL (https). Once people start following your instance you should not change this.',
|
||||||
|
type: TEXTFIELD_TYPE_URL,
|
||||||
|
pattern: DEFAULT_TEXTFIELD_URL_PATTERN,
|
||||||
|
useTrim: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_PROPS_FEDERATION_NSFW = {
|
||||||
|
apiPath: API_NSFW_SWITCH,
|
||||||
|
configPath: 'instanceDetails',
|
||||||
|
label: 'Potentially NSFW',
|
||||||
|
tip: 'Turn this ON if you plan to steam explicit or adult content so previews of your stream can be marked as potentially sensitive.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS = {
|
||||||
|
apiPath: API_FEDERATION_BLOCKED_DOMAINS,
|
||||||
|
configPath: 'federation',
|
||||||
|
label: 'Blocked domains',
|
||||||
|
placeholder: 'bad.domain.biz',
|
||||||
|
tip: 'You can block specific domains from interacting with you.',
|
||||||
|
};
|
||||||
|
|
||||||
export const VIDEO_VARIANT_SETTING_DEFAULTS = {
|
export const VIDEO_VARIANT_SETTING_DEFAULTS = {
|
||||||
// this one is currently unused
|
// this one is currently unused
|
||||||
audioBitrate: {
|
audioBitrate: {
|
||||||
|
@ -45,6 +45,14 @@ export const initialServerConfigState: ConfigDetails = {
|
|||||||
cpuUsageLevel: 3,
|
cpuUsageLevel: 3,
|
||||||
videoQualityVariants: [DEFAULT_VARIANT_STATE],
|
videoQualityVariants: [DEFAULT_VARIANT_STATE],
|
||||||
},
|
},
|
||||||
|
federation: {
|
||||||
|
enabled: false,
|
||||||
|
isPrivate: false,
|
||||||
|
username: '',
|
||||||
|
goLiveMessage: '',
|
||||||
|
showEngagement: true,
|
||||||
|
blockedDomains: [],
|
||||||
|
},
|
||||||
externalActions: [],
|
externalActions: [],
|
||||||
supportedCodecs: [],
|
supportedCodecs: [],
|
||||||
videoCodec: '',
|
videoCodec: '',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user