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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user