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:
Gabe Kangas 2022-01-12 13:52:37 -08:00 committed by GitHub
parent 53d60f5127
commit 084a01fb02
18 changed files with 1068 additions and 42 deletions

View file

@ -30,8 +30,10 @@ export default function ClientTable({ data }: ClientTableProps) {
dataIndex: 'messageCount',
key: 'messageCount',
className: 'number-col',
width: '12%',
sorter: (a: any, b: any) => a.messageCount - b.messageCount,
sortDirections: ['descend', 'ascend'] as SortOrder[],
render: (count: number) => <div style={{ textAlign: 'center' }}>{count}</div>,
},
{
title: 'Connected Time',

View 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>
);
}

View file

@ -55,7 +55,7 @@ export default function EditValueArray(props: EditStringArrayProps) {
<p className="description">{description}</p>
<div className="edit-current-strings">
{values.map((tag, index) => {
{values?.map((tag, index) => {
const handleClose = () => {
handleDeleteIndex(index);
};

View file

@ -4,8 +4,7 @@ import Link from 'next/link';
import Head from 'next/head';
import { differenceInSeconds } from 'date-fns';
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 {
SettingOutlined,
HomeOutlined,
@ -16,6 +15,7 @@ import {
QuestionCircleOutlined,
MessageOutlined,
ExperimentOutlined,
EditOutlined,
} from '@ant-design/icons';
import classNames from 'classnames';
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 { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
import ComposeFederatedPost from './compose-federated-post';
import { UpdateArgs } from '../types/config-section';
// eslint-disable-next-line react/function-component-definition
@ -36,9 +36,11 @@ export default function MainLayout(props) {
const context = useContext(ServerStatusContext);
const { serverConfig, online, broadcaster, versionNumber } = context || {};
const { instanceDetails, chatDisabled } = serverConfig;
const { instanceDetails, chatDisabled, federation } = serverConfig;
const { enabled: federationEnabled } = federation;
const [currentStreamTitle, setCurrentStreamTitle] = useState('');
const [postModalDisplayed, setPostModalDisplayed] = useState(false);
const alertMessage = useContext(AlertMessageContext);
@ -70,6 +72,10 @@ export default function MainLayout(props) {
setCurrentStreamTitle(value);
};
const handleCreatePostButtonPressed = () => {
setPostModalDisplayed(true);
};
const appClass = classNames({
'app-container': true,
online,
@ -94,12 +100,7 @@ export default function MainLayout(props) {
? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time)))
: '';
const currentThumbnail = online ? (
<img
src="/thumbnail.jpg"
className="online-thumbnail"
alt="current thumbnail"
style={{ width: '10rem' }}
/>
<img src="/thumbnail.jpg" className="online-thumbnail" alt="current thumbnail" width="1rem" />
) : null;
const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
const statusMessage = online ? `Online ${streamDurationString}` : 'Offline';
@ -162,6 +163,22 @@ export default function MainLayout(props) {
</Menu.Item>
</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 />}>
<Menu.Item key="config-public-details">
<Link href="/config-public-details">General</Link>
@ -171,11 +188,15 @@ export default function MainLayout(props) {
<Link href="/config-server-details">Server Setup</Link>
</Menu.Item>
<Menu.Item key="config-video">
<Link href="/config-video">Video Configuration</Link>
<Link href="/config-video">Video</Link>
</Menu.Item>
<Menu.Item key="config-chat">
<Link href="/config-chat">Chat</Link>
</Menu.Item>
<Menu.Item key="config-federation">
<Link href="/config-federation">Social</Link>
</Menu.Item>
<Menu.Item key="config-storage">
<Link href="/config-storage">S3 Storage</Link>
</Menu.Item>
@ -188,6 +209,9 @@ export default function MainLayout(props) {
<Menu.Item key="logs">
<Link href="/logs">Logs</Link>
</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 }}>
<Link href="/upgrade">{upgradeMessage}</Link>
</Menu.Item>
@ -211,6 +235,18 @@ export default function MainLayout(props) {
<Layout className="layout-main">
<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">
<TextFieldWithSubmit
fieldName="streamTitle"
@ -221,8 +257,7 @@ export default function MainLayout(props) {
onChange={handleStreamTitleChanged}
/>
</div>
{statusIndicatorWithThumb}
<Space direction="horizontal">{statusIndicatorWithThumb}</Space>
</Header>
{headerAlertMessage}
@ -235,6 +270,11 @@ export default function MainLayout(props) {
</a>
</Footer>
</Layout>
<ComposeFederatedPost
visible={postModalDisplayed}
handleClose={() => setPostModalDisplayed(false)}
/>
</Layout>
);
}

View file

@ -1,5 +1,10 @@
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 } from '../types/chat';
@ -70,7 +75,13 @@ export default function ModeratorUserButton({ user, onClick }: ModeratorUserButt
<Button
onClick={confirmBlockAction}
size="small"
icon={isModerator ? <StopTwoTone twoToneColor="#ff4d4f" /> : null}
icon={
isModerator ? (
<StopTwoTone twoToneColor="#ff4d4f" />
) : (
<SafetyCertificateTwoTone twoToneColor="#22bb44" />
)
}
className="block-user-button"
>
{actionString}

View file

@ -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.
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 format from 'date-fns/format';
import { uniq } from 'lodash';
@ -117,27 +117,29 @@ export default function UserPopover({ user, connectionInfo, children }: UserPopo
)}
</Row>
<Divider />
{disabledAt ? (
<>
This user was banned on <code>{formatDisplayDate(disabledAt)}</code>.
<br />
<br />
<Space direction="horizontal">
{disabledAt ? (
<>
This user was banned on <code>{formatDisplayDate(disabledAt)}</code>.
<br />
<br />
<BlockUserbutton
label="Unban this user"
user={user}
isEnabled={false}
onClick={handleCloseModal}
/>
</>
) : (
<BlockUserbutton
label="Unban this user"
label="Ban this user"
user={user}
isEnabled={false}
isEnabled
onClick={handleCloseModal}
/>
</>
) : (
<BlockUserbutton
label="Ban this user"
user={user}
isEnabled
onClick={handleCloseModal}
/>
)}
<ModeratorUserbutton user={user} onClick={handleCloseModal} />
)}
<ModeratorUserbutton user={user} onClick={handleCloseModal} />
</Space>
</div>
</Modal>
</>

1
web/next-env.d.ts vendored
View file

@ -1,5 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited

16
web/package-lock.json generated
View file

@ -1387,9 +1387,13 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001243",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA=="
"version": "1.0.30001297",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz",
"integrity": "sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
}
},
"node_modules/chalk": {
"version": "2.4.2",
@ -7663,9 +7667,9 @@
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001243",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA=="
"version": "1.0.30001297",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz",
"integrity": "sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA=="
},
"chalk": {
"version": "2.4.2",

View 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>
);
}

View 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>
);
}

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -32,7 +32,7 @@ p,
p.description,
.description,
.ant-typography {
font-weight: 300;
font-weight: 500;
margin: 1em 0;
color: var(--white-75);
}
@ -158,3 +158,19 @@ input {
.ant-tabs-ink-bar {
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);
}
}

View file

@ -44,6 +44,7 @@
flex-direction: row;
justify-content: flex-end;
padding-right: 1rem;
padding-left: 1rem;
background-color: var(--nav-bg-color);
}

View file

@ -89,6 +89,15 @@ export interface ExternalAction {
openExternally: boolean;
}
export interface Federation {
enabled: boolean;
isPrivate: boolean;
username: string;
goLiveMessage: string;
showEngagement: boolean;
blockedDomains: string[];
}
export interface ConfigDetails {
externalActions: ExternalAction[];
ffmpegPath: string;
@ -104,4 +113,5 @@ export interface ConfigDetails {
forbiddenUsernames: string[];
suggestedUsernames: string[];
chatDisabled: boolean;
federation: Federation;
}

View file

@ -79,6 +79,24 @@ export const SOCIAL_PLATFORMS_LIST = `${NEXT_PUBLIC_API_HOST}api/socialplatforms
// set external action links
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 TEMP_UPDATER_API = LOGS_ALL;

View file

@ -35,6 +35,14 @@ export const API_CHAT_SUGGESTED_USERNAMES = '/chat/suggestedusernames';
export const API_EXTERNAL_ACTIONS = '/externalactions';
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) {
const { apiPath, data, onSuccess, onError } = args;
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.',
};
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 = {
// this one is currently unused
audioBitrate: {

View file

@ -45,6 +45,14 @@ export const initialServerConfigState: ConfigDetails = {
cpuUsageLevel: 3,
videoQualityVariants: [DEFAULT_VARIANT_STATE],
},
federation: {
enabled: false,
isPrivate: false,
username: '',
goLiveMessage: '',
showEngagement: true,
blockedDomains: [],
},
externalActions: [],
supportedCodecs: [],
videoCodec: '',