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 @@ |
|||||||
|
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 @@ |
|||||||
|
/* 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 @@ |
|||||||
|
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 @@ |
|||||||
|
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