Browse Source
* 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 caniusepull/1886/head
18 changed files with 1068 additions and 42 deletions
@ -0,0 +1,76 @@
@@ -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> |
||||
); |
||||
} |
||||
@ -0,0 +1,323 @@
@@ -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> |
||||
); |
||||
} |
||||
@ -0,0 +1,120 @@
@@ -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> |
||||
); |
||||
} |
||||
@ -0,0 +1,318 @@
@@ -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> |
||||
); |
||||
} |
||||
|
After Width: | Height: | Size: 4.8 KiB |
Loading…
Reference in new issue