SBL
This commit is contained in:
parent
6965c4b99c
commit
a34aa6969f
27 changed files with 1498 additions and 9 deletions
61
flow-typed/Comment.js
vendored
61
flow-typed/Comment.js
vendored
|
@ -20,6 +20,13 @@ declare type Comment = {
|
|||
is_fiat?: boolean,
|
||||
};
|
||||
|
||||
declare type CommentronAuth = {
|
||||
channel_name: string,
|
||||
channel_id: string,
|
||||
signature: string,
|
||||
signing_ts: string,
|
||||
};
|
||||
|
||||
declare type PerChannelSettings = {
|
||||
words?: Array<string>,
|
||||
comments_enabled?: boolean,
|
||||
|
@ -68,6 +75,10 @@ declare type CommentsState = {
|
|||
settingsByChannelId: { [string]: PerChannelSettings }, // ChannelID -> settings
|
||||
fetchingSettings: boolean,
|
||||
fetchingBlockedWords: boolean,
|
||||
sblMine: any,
|
||||
sblInvited: Array<any>,
|
||||
fetchingSblMine: boolean,
|
||||
fetchingSblInvited: boolean,
|
||||
};
|
||||
|
||||
declare type CommentReactParams = {
|
||||
|
@ -298,3 +309,53 @@ declare type BlockWordParams = {
|
|||
signing_ts: string,
|
||||
words: string, // CSV list of containing words to block comment on content
|
||||
};
|
||||
|
||||
declare type SblUpdate = {
|
||||
name?: string, // A user friendly identifier for the owner/users.
|
||||
category?: string, // The category of block list this is so others search
|
||||
description?: string,
|
||||
member_invite_enabled?: boolean, // Can members invite others contributors?
|
||||
// Strikes are number of hours a user should be banned for if
|
||||
// part of this blocked list. Strikes 1,2,3 are intended to be
|
||||
// progressively higher. Strike 3 is the highest.
|
||||
strike_one?: number,
|
||||
strike_two?: number,
|
||||
strike_three?: number,
|
||||
invite_expiration?: number, // The number of hours until a sent invite expires.
|
||||
// Curse jar allows automatic appeals. If they tip the owner of
|
||||
// the shared blocked list their appeal is automatically accepted.
|
||||
curse_jar_amount?: number,
|
||||
remove?: boolean,
|
||||
};
|
||||
|
||||
declare type SharedBlockedListUpdateArgs = CommentronAuth & SblUpdate;
|
||||
|
||||
declare type SblGet = {
|
||||
blocked_list_id?: number,
|
||||
status: number, // @see: SBL_INVITE_STATUS
|
||||
};
|
||||
|
||||
declare type SharedBlockedListGetArgs = ?CommentronAuth & SblGet;
|
||||
|
||||
declare type SblInvite = {
|
||||
blocked_list_id?: number,
|
||||
invitee_channel_name: string,
|
||||
invitee_channel_id: string,
|
||||
message: string,
|
||||
};
|
||||
|
||||
declare type SharedBlockedListInviteArgs = CommentronAuth & SblInvite;
|
||||
|
||||
declare type SblInviteAccept = {
|
||||
blocked_list_id: number,
|
||||
accepted: boolean,
|
||||
};
|
||||
|
||||
declare type SharedBlockedListInviteAcceptArgs = CommentronAuth & SblInviteAccept;
|
||||
|
||||
declare type SblRescind = {
|
||||
invited_channel_name: string,
|
||||
invited_channel_id: string,
|
||||
};
|
||||
|
||||
declare type SharedBlockedListRescindArgs = CommentronAuth & SblRescind;
|
||||
|
|
|
@ -36,6 +36,12 @@ const Comments = {
|
|||
setting_update: (params: UpdateSettingsParams) => fetchCommentsApi('setting.Update', params),
|
||||
setting_get: (params: SettingsParams) => fetchCommentsApi('setting.Get', params),
|
||||
super_list: (params: SuperListParams) => fetchCommentsApi('comment.SuperChatList', params),
|
||||
sbl_update: (params: SharedBlockedListUpdateArgs) => fetchCommentsApi('blockedlist.Update', params),
|
||||
sbl_get: (params: SharedBlockedListGetArgs) => fetchCommentsApi('blockedlist.Get', params),
|
||||
sbl_invite: (params: SharedBlockedListInviteArgs) => fetchCommentsApi('blockedlist.Invite', params),
|
||||
sbl_list_invites: (params: CommentronAuth) => fetchCommentsApi('blockedlist.ListInvites', params),
|
||||
sbl_accept: (params: SharedBlockedListInviteAcceptArgs) => fetchCommentsApi('blockedlist.Accept', params),
|
||||
sbl_rescind: (params: SharedBlockedListRescindArgs) => fetchCommentsApi('blockedlist.Rescind', params),
|
||||
};
|
||||
|
||||
function fetchCommentsApi(method: string, params: {}) {
|
||||
|
|
33
ui/component/blockListShared/index.js
Normal file
33
ui/component/blockListShared/index.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { doSblGet, doSblAccept, doSblListInvites, doSblRescind, doSblDelete } from 'redux/actions/comments';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
import {
|
||||
selectSblMine,
|
||||
selectFetchingSblMine,
|
||||
selectFetchingSblInvited,
|
||||
selectSblInvited,
|
||||
} from 'redux/selectors/comments';
|
||||
|
||||
import BlockListShared from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
sblMine: selectSblMine(state),
|
||||
sblInvited: selectSblInvited(state),
|
||||
fetchingSblMine: selectFetchingSblMine(state),
|
||||
fetchingSblInvited: selectFetchingSblInvited(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doSblGet: (channelClaim, params) => dispatch(doSblGet(channelClaim, params)),
|
||||
doSblListInvites: (channelClaim) => dispatch(doSblListInvites(channelClaim)),
|
||||
doSblAccept: (channelClaim, params, onComplete) => dispatch(doSblAccept(channelClaim, params, onComplete)),
|
||||
doSblRescind: (channelClaim, params, onComplete) => dispatch(doSblRescind(channelClaim, params, onComplete)),
|
||||
doSblDelete: (channelClaim) => dispatch(doSblDelete(channelClaim)),
|
||||
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
doToast: (options) => dispatch(doToast(options)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(BlockListShared);
|
452
ui/component/blockListShared/view.jsx
Normal file
452
ui/component/blockListShared/view.jsx
Normal file
|
@ -0,0 +1,452 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import Button from 'component/button';
|
||||
import Card from 'component/common/card';
|
||||
import CreditAmount from 'component/common/credit-amount';
|
||||
import Empty from 'component/common/empty';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import TruncatedText from 'component/common/truncated-text';
|
||||
import ChannelSelector from 'component/channelSelector';
|
||||
import ClaimList from 'component/claimList';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import Spinner from 'component/spinner';
|
||||
import { SBL_INVITE_STATUS } from 'constants/comment';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import useFetched from 'effects/use-fetched';
|
||||
import { getHoursStr, getYesNoStr } from 'util/string';
|
||||
import { isURIValid } from 'lbry-redux';
|
||||
|
||||
const PARAM_BLOCKLIST_ID = 'id';
|
||||
|
||||
type Props = {
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
sblMine: any,
|
||||
sblInvited: any,
|
||||
fetchingSblMine: boolean,
|
||||
fetchingSblInvited: boolean,
|
||||
doSblGet: (channelClaim: ?ChannelClaim, params: SblGet) => void,
|
||||
doSblListInvites: (channelClaim: ChannelClaim) => void,
|
||||
doSblAccept: (channelClaim: ChannelClaim, params: SblInviteAccept, onComplete: (err: string) => void) => void,
|
||||
doSblRescind: (channelClaim: ChannelClaim, params: SblRescind, onComplete: (err: string) => void) => void,
|
||||
doSblDelete: (channelClaim: ChannelClaim) => void,
|
||||
doOpenModal: (string, {}) => void,
|
||||
doToast: ({ message: string }) => void,
|
||||
};
|
||||
|
||||
export default function BlockListShared(props: Props) {
|
||||
const {
|
||||
activeChannelClaim,
|
||||
sblMine,
|
||||
sblInvited,
|
||||
fetchingSblMine,
|
||||
fetchingSblInvited,
|
||||
doSblGet,
|
||||
doSblListInvites,
|
||||
doSblAccept,
|
||||
doSblRescind,
|
||||
doSblDelete,
|
||||
doOpenModal,
|
||||
doToast,
|
||||
} = props;
|
||||
const { push } = useHistory();
|
||||
|
||||
const [showMembers, setShowMembers] = React.useState(false);
|
||||
const [expandInvite, setExpandInvite] = React.useState({});
|
||||
|
||||
const members = sblMine && sblMine.invited_members;
|
||||
const memberUris = members && members.map((m) => `lbry://${m.invited_channel_name}#${m.invited_channel_id}`);
|
||||
|
||||
// I think the only way to check if my SBL is active is by checking whether we accepted any invites to other SBLs.
|
||||
const isMySblActive = sblMine && (!sblInvited || !sblInvited.some((i) => i.invitation.status === 'accepted'));
|
||||
const mySblId = sblMine && sblMine.shared_blocked_list.id;
|
||||
|
||||
const isUpdating = fetchingSblMine || fetchingSblInvited;
|
||||
const fetchedOnce = useFetched(isUpdating);
|
||||
|
||||
// **************************************************************************
|
||||
// **************************************************************************
|
||||
|
||||
function fetchSblInfo() {
|
||||
if (activeChannelClaim) {
|
||||
doSblGet(activeChannelClaim, {
|
||||
blocked_list_id: undefined,
|
||||
status: SBL_INVITE_STATUS.ALL,
|
||||
});
|
||||
|
||||
doSblListInvites(activeChannelClaim);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSblDelete() {
|
||||
doOpenModal(MODALS.CONFIRM, {
|
||||
title: __('Delete'),
|
||||
body: (
|
||||
<>
|
||||
<p>{__('Are you sure you want to delete the shared blocklist?')}</p>
|
||||
<p className="empty__text">{sblMine.shared_blocked_list.name.substring(0, 256)}</p>
|
||||
</>
|
||||
),
|
||||
onConfirm: (closeModal) => {
|
||||
if (activeChannelClaim) {
|
||||
doSblDelete(activeChannelClaim);
|
||||
}
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSblAccept(sblId: number, accepted: boolean) {
|
||||
doOpenModal(MODALS.CONFIRM, {
|
||||
title: accepted ? __('Accept invite') : __('Reject invite'),
|
||||
body: accepted ? (
|
||||
<>
|
||||
<p>{__('Participate in the selected shared blocklist?')}</p>
|
||||
<p className="help">{__('Any channels that you block in the future will be added to the list.')}</p>
|
||||
<p className="help">
|
||||
{__(
|
||||
'This will become the active shared blocklist, replacing your own (if exists) or any shared blocklist that you are currently participating.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{__('Stop participating from the selected shared blocklist?')}</p>
|
||||
</>
|
||||
),
|
||||
onConfirm: (closeModal, setIsBusy) => {
|
||||
if (activeChannelClaim) {
|
||||
const params = {
|
||||
blocked_list_id: sblId,
|
||||
accepted: accepted,
|
||||
};
|
||||
|
||||
setIsBusy(true);
|
||||
|
||||
doSblAccept(activeChannelClaim, params, (err: string) => {
|
||||
if (err) {
|
||||
doToast({ message: err, isError: true });
|
||||
} else {
|
||||
fetchSblInfo();
|
||||
setTimeout(() => {
|
||||
setIsBusy(false);
|
||||
closeModal();
|
||||
doToast({ message: accepted ? __('Invite accepted.') : __('Invite rejected.') });
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleRescind(memberClaim, expired) {
|
||||
doOpenModal(MODALS.CONFIRM, {
|
||||
title: expired ? __('Remove') : __('Rescind'),
|
||||
body: (
|
||||
<>
|
||||
{expired && <p>{__('Remove the invitation for the following member?')}</p>}
|
||||
{!expired && <p>{__('Rescind the invitation for the following member?')}</p>}
|
||||
<p className="empty__text">{memberClaim.name.substring(0, 256)}</p>
|
||||
</>
|
||||
),
|
||||
onConfirm: (closeModal, setIsBusy) => {
|
||||
if (activeChannelClaim) {
|
||||
const params = {
|
||||
invited_channel_name: memberClaim.name,
|
||||
invited_channel_id: memberClaim.claim_id,
|
||||
};
|
||||
|
||||
setIsBusy(true);
|
||||
|
||||
doSblRescind(activeChannelClaim, params, (/* err */) => {
|
||||
// Fetch the new data and wait a bit before we dismiss the modal.
|
||||
// If you hate the hardcoded timeout, then add a callback for the
|
||||
// fetch actions.
|
||||
fetchSblInfo();
|
||||
setTimeout(() => {
|
||||
setIsBusy(false);
|
||||
closeModal();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getHelpElem() {
|
||||
return (
|
||||
<I18nMessage
|
||||
// TODO: Need URL
|
||||
tokens={{
|
||||
learn_more: <Button button="link" label={__('Learn more')} href="https://odysee.com/@OdyseeHelp:b" />,
|
||||
}}
|
||||
>
|
||||
Shared blocklists allow a group of creators to all mutually manage a set of blocks that applies to all of their
|
||||
channels. %learn_more%
|
||||
</I18nMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function getRowElem(label: string, value: any, truncate: boolean = false) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{label}</td>
|
||||
{truncate && (
|
||||
<td>
|
||||
<TruncatedText lines={1} text={value} showTooltip />
|
||||
</td>
|
||||
)}
|
||||
{!truncate && <td>{value}</td>}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function getSwearJarAmountElem(amount: number) {
|
||||
return (amount && <CreditAmount amount={amount} precision={4} />) || <LbcSymbol prefix={__('---')} size={14} />;
|
||||
}
|
||||
|
||||
function getSblStatusElem(active) {
|
||||
if (active) {
|
||||
return (
|
||||
<div className="sbl-status--active" title={__('This is the current active shared blocklist.')}>
|
||||
{__('Active')}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className="sbl-status--inactive"
|
||||
title={__('This blocklist is currently inactive. Only 1 blocklist can be active at a time.')}
|
||||
>
|
||||
{__('Inactive')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getSblInfoElem(sbl) {
|
||||
const expanded = expandInvite[sbl.id] || sbl.id === mySblId;
|
||||
return (
|
||||
<div className="sbl-info">
|
||||
{expanded && (
|
||||
<table className="table table--condensed table--publish-preview table--no-row-lines">
|
||||
<tbody>
|
||||
{getRowElem(__('Name'), sbl.name)}
|
||||
{getRowElem(__('Description'), sbl.description)}
|
||||
{getRowElem(__('Category'), sbl.category)}
|
||||
{getRowElem(__('Strike 1'), getHoursStr(sbl.strike_one))}
|
||||
{getRowElem(__('Strike 2'), getHoursStr(sbl.strike_two))}
|
||||
{getRowElem(__('Strike 3'), getHoursStr(sbl.strike_three))}
|
||||
{getRowElem(__('Auto-appeal minimum'), getSwearJarAmountElem(sbl.curse_jar_amount))}
|
||||
{getRowElem(__('Invite expiration'), getHoursStr(sbl.invite_expiration))}
|
||||
{getRowElem(__('Allow members to invite others'), getYesNoStr(sbl.member_invite_enabled))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{sbl.id !== mySblId && (
|
||||
<Button
|
||||
button="link"
|
||||
className="expandable__button"
|
||||
icon={expanded ? ICONS.UP : ICONS.DOWN}
|
||||
onClick={() => setExpandInvite({ ...expandInvite, [sbl.id]: !expanded })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMySblElem() {
|
||||
if (sblMine) {
|
||||
const sbl = sblMine.shared_blocked_list;
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div>
|
||||
{__('Your Shared Blocklist')}
|
||||
{isUpdating && <Spinner type="small" />}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<div className="section__actions--between">
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="alt"
|
||||
label={__('Invite')}
|
||||
title={__('Invite others to your shared blocklist.')}
|
||||
icon={ICONS.INVITE}
|
||||
navigate={`/$/${PAGES.SHARED_BLOCKLIST_INVITE}?${PARAM_BLOCKLIST_ID}=${sbl.id}`}
|
||||
disabled={!isMySblActive}
|
||||
/>
|
||||
<Button
|
||||
button="alt"
|
||||
label={__('Edit')}
|
||||
title={__('Make changes to your shared blocklist.')}
|
||||
icon={ICONS.EDIT}
|
||||
onClick={() => {
|
||||
push({
|
||||
pathname: `/$/${PAGES.SHARED_BLOCKLIST_EDIT}`,
|
||||
state: sbl,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
button="alt"
|
||||
label={__('Delete')}
|
||||
title={__('Delete your shared blocklist.')}
|
||||
icon={ICONS.DELETE}
|
||||
onClick={handleSblDelete}
|
||||
/>
|
||||
</div>
|
||||
{getSblStatusElem(isMySblActive)}
|
||||
</div>
|
||||
|
||||
<div className="section__actions">{getSblInfoElem(sbl)}</div>
|
||||
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="link"
|
||||
label={showMembers ? __('Hide members') : __('Show members')}
|
||||
icon={showMembers ? ICONS.UP : ICONS.DOWN}
|
||||
onClick={() => setShowMembers(!showMembers)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="section">{getMembersElem()}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Card
|
||||
title={__('Your Shared Blocklist')}
|
||||
actions={<Button button="primary" label={__('Create list')} navigate={`/$/${PAGES.SHARED_BLOCKLIST_EDIT}`} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getMembersElem() {
|
||||
if (!showMembers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getInviteStatus = (members, claim) => {
|
||||
const member = members.find((x) => x.invited_channel_id === claim.claim_id);
|
||||
return member && member.status;
|
||||
};
|
||||
|
||||
// This is required until Commentron filters bad invites.
|
||||
const sanitizedMemberUris = memberUris && memberUris.filter((uri) => isURIValid(uri));
|
||||
|
||||
if (sanitizedMemberUris && sanitizedMemberUris.length > 0) {
|
||||
return (
|
||||
<ClaimList
|
||||
uris={sanitizedMemberUris}
|
||||
hideMenu
|
||||
renderProperties={(claim) => {
|
||||
const status = getInviteStatus(members, claim);
|
||||
return status === 'expired' ? (
|
||||
<div className="sbl_invite_expired">{__('Expired')}</div>
|
||||
) : status === 'accepted' ? (
|
||||
<div className="sbl_invite_accepted">{__('Accepted')}</div>
|
||||
) : null;
|
||||
}}
|
||||
renderActions={(claim) => {
|
||||
const expired = getInviteStatus(members, claim) === 'expired';
|
||||
return (
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button={expired ? 'alt' : 'secondary'}
|
||||
icon={ICONS.REMOVE}
|
||||
label={expired ? __('Remove') : __('Rescind')}
|
||||
onClick={() => handleRescind(claim, expired)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <Empty text={__('No members invited.')} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getInvitedSblElem() {
|
||||
if (sblInvited && sblInvited.length > 0) {
|
||||
return (
|
||||
<Card
|
||||
title={__('Invitations')}
|
||||
isBodyList
|
||||
body={
|
||||
<>
|
||||
{sblInvited.map((i) => {
|
||||
return (
|
||||
<div key={i.shared_blocked_list.id} className="card__main-actions">
|
||||
<div className="section__actions--between">
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="alt"
|
||||
label={i.invitation.status === 'accepted' ? __('Disable') : __('Enable')}
|
||||
title={
|
||||
i.invitation.status === 'accepted'
|
||||
? __('Stop using this blocklist.')
|
||||
: __('Accept and activate this blocklist.')
|
||||
}
|
||||
icon={i.invitation.status === 'accepted' ? ICONS.REMOVE : undefined}
|
||||
onClick={() => handleSblAccept(i.shared_blocked_list.id, i.invitation.status !== 'accepted')}
|
||||
/>
|
||||
{i.invitation.status === 'accepted' && i.shared_blocked_list.member_invite_enabled && (
|
||||
<Button
|
||||
button="alt"
|
||||
label={__('Invite')}
|
||||
title={__('Invite others to this shared blocklist on behalf of the owner.')}
|
||||
icon={ICONS.INVITE}
|
||||
navigate={`/$/${PAGES.SHARED_BLOCKLIST_INVITE}?${PARAM_BLOCKLIST_ID}=${i.shared_blocked_list.id}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{getSblStatusElem(i.invitation.status === 'accepted')}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<table className="table table--condensed table--publish-preview table--no-row-lines">
|
||||
<tbody>
|
||||
{getRowElem(__('From'), i.invitation.invited_by_channel_name)}
|
||||
{getRowElem(__('Message'), i.invitation.message, !expandInvite[i.shared_blocked_list.id])}
|
||||
</tbody>
|
||||
</table>
|
||||
{getSblInfoElem(i.shared_blocked_list)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// **************************************************************************
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchSblInfo();
|
||||
}, [activeChannelClaim, doSblGet, doSblListInvites]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className={!fetchedOnce ? 'card--disabled' : isUpdating ? 'card--disable-interaction' : ''}>
|
||||
<div className="section help--notice">{getHelpElem()}</div>
|
||||
<div className="section">
|
||||
<ChannelSelector hideAnon />
|
||||
</div>
|
||||
<div className="section sbl">{getMySblElem()}</div>
|
||||
<div className="section sbl">{getInvitedSblElem()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2439,6 +2439,14 @@ export const icons = {
|
|||
/>
|
||||
</svg>
|
||||
),
|
||||
[ICONS.APPEAL]: buildIcon(
|
||||
<g>
|
||||
<path d="M13.54,8.58l9.33,8.79a1.31,1.31,0,0,1,0,1.85l-.93.93a1.31,1.31,0,0,1-1.85,0L11.3,10.82" />
|
||||
<path d="M3.85,8.54a19.06,19.06,0,0,0,7.41-7.41,1.31,1.31,0,0,1,1.85,0l3.51,3.51a1.32,1.32,0,0,1,0,1.86,18.09,18.09,0,0,0-7.41,7.4,1.31,1.31,0,0,1-1.85,0L3.85,10.39A1.31,1.31,0,0,1,3.85,8.54Z" />
|
||||
<path d="M12.75,20.75h0a1.5,1.5,0,0,0-1.5-1.5H3.75a1.5,1.5,0,0,0-1.5,1.5h0v2.5h10.5Z" />
|
||||
<line x1="0.75" y1="23.25" x2="14.25" y2="23.25" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.PLAY]: buildIcon(<polygon points="5 3 19 12 5 21 5 3" />),
|
||||
[ICONS.PLAY_PREVIOUS]: buildIcon(
|
||||
<g>
|
||||
|
|
|
@ -37,6 +37,8 @@ const SwapPage = lazyImport(() => import('page/swap' /* webpackChunkName: "secon
|
|||
const WalletPage = lazyImport(() => import('page/wallet' /* webpackChunkName: "secondary" */));
|
||||
|
||||
// Chunk: none
|
||||
const AppealListOffences = lazyImport(() => import('page/appealListOffences' /* webpackChunkName: "appealOffences" */));
|
||||
const AppealReview = lazyImport(() => import('page/appealReview' /* webpackChunkName: "appealReview" */));
|
||||
const NotificationsPage = lazyImport(() => import('page/notifications' /* webpackChunkName: "secondary" */));
|
||||
const CollectionPage = lazyImport(() => import('page/collection' /* webpackChunkName: "secondary" */));
|
||||
const ChannelNew = lazyImport(() => import('page/channelNew' /* webpackChunkName: "secondary" */));
|
||||
|
@ -77,6 +79,10 @@ const SettingsNotificationsPage = lazyImport(() =>
|
|||
import('page/settingsNotifications' /* webpackChunkName: "secondary" */)
|
||||
);
|
||||
const SettingsPage = lazyImport(() => import('page/settings' /* webpackChunkName: "secondary" */));
|
||||
const SharedBlocklistEdit = lazyImport(() => import('page/sharedBlocklistEdit' /* webpackChunkName: "sblEdit" */));
|
||||
const SharedBlocklistInvite = lazyImport(() =>
|
||||
import('page/sharedBlocklistInvite' /* webpackChunkName: "sblInvite" */)
|
||||
);
|
||||
const ShowPage = lazyImport(() => import('page/show' /* webpackChunkName: "secondary" */));
|
||||
const TagsFollowingManagePage = lazyImport(() =>
|
||||
import('page/tagsFollowingManage' /* webpackChunkName: "secondary" */)
|
||||
|
@ -318,6 +324,10 @@ function AppRouter(props: Props) {
|
|||
<PrivateRoute {...props} path={`/$/${PAGES.LISTS}`} component={ListsPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.SHARED_BLOCKLIST_EDIT}`} component={SharedBlocklistEdit} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.SHARED_BLOCKLIST_INVITE}`} component={SharedBlocklistInvite} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.APPEAL_LIST_OFFENCES}`} component={AppealListOffences} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.APPEAL_CREATOR_REVIEW}`} component={AppealReview} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_CREATOR}`} component={SettingsCreatorPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} />
|
||||
|
|
|
@ -301,6 +301,13 @@ export const COMMENT_RECEIVED = 'COMMENT_RECEIVED';
|
|||
export const COMMENT_SUPER_CHAT_LIST_STARTED = 'COMMENT_SUPER_CHAT_LIST_STARTED';
|
||||
export const COMMENT_SUPER_CHAT_LIST_COMPLETED = 'COMMENT_SUPER_CHAT_LIST_COMPLETED';
|
||||
export const COMMENT_SUPER_CHAT_LIST_FAILED = 'COMMENT_SUPER_CHAT_LIST_FAILED';
|
||||
export const COMMENT_SBL_FETCH_STARTED = 'COMMENT_SBL_FETCH_STARTED';
|
||||
export const COMMENT_SBL_FETCH_FAILED = 'COMMENT_SBL_FETCH_FAILED';
|
||||
export const COMMENT_SBL_FETCH_COMPLETED = 'COMMENT_SBL_FETCH_COMPLETED';
|
||||
export const COMMENT_SBL_FETCH_INVITES_STARTED = 'COMMENT_SBL_FETCH_INVITES_STARTED';
|
||||
export const COMMENT_SBL_FETCH_INVITES_FAILED = 'COMMENT_SBL_FETCH_INVITES_FAILED';
|
||||
export const COMMENT_SBL_FETCH_INVITES_COMPLETED = 'COMMENT_SBL_FETCH_INVITES_COMPLETED';
|
||||
export const COMMENT_SBL_DELETED = 'COMMENT_SBL_DELETED';
|
||||
|
||||
// Blocked channels
|
||||
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
|
||||
|
|
|
@ -19,3 +19,15 @@ export const BLOCK_LEVEL = {
|
|||
|
||||
export const COMMENT_PAGE_SIZE_TOP_LEVEL = 10;
|
||||
export const COMMENT_PAGE_SIZE_REPLIES = 10;
|
||||
|
||||
// ***************************************************************************
|
||||
// SBL: Shared Blocked List
|
||||
// ***************************************************************************
|
||||
|
||||
export const SBL_INVITE_STATUS = {
|
||||
ALL: 0,
|
||||
PENDING: 1,
|
||||
ACCEPTED: 2,
|
||||
REJECTED: 3,
|
||||
NONE: 4,
|
||||
};
|
||||
|
|
|
@ -171,6 +171,7 @@ export const STAR = 'star';
|
|||
export const MUSIC = 'MusicCategory';
|
||||
export const BADGE_MOD = 'BadgeMod';
|
||||
export const BADGE_STREAMER = 'BadgeStreamer';
|
||||
export const APPEAL = 'JudgeHammer';
|
||||
export const REPLAY = 'Replay';
|
||||
export const REPEAT = 'Repeat';
|
||||
export const SHUFFLE = 'Shuffle';
|
||||
|
|
|
@ -74,5 +74,11 @@ exports.NOTIFICATIONS = 'notifications';
|
|||
exports.YOUTUBE_SYNC = 'youtube';
|
||||
exports.LIVESTREAM = 'livestream';
|
||||
exports.LIVESTREAM_CURRENT = 'live';
|
||||
exports.SHARED_BLOCKLIST_EDIT = 'sharedblocklist/edit';
|
||||
exports.SHARED_BLOCKLIST_INVITE = 'sharedblocklist/invite';
|
||||
exports.APPEAL_LIST_OFFENCES = 'appeal/offences';
|
||||
exports.APPEAL_FILE = 'appeal/file';
|
||||
exports.APPEAL_FILED = 'appeal/filed';
|
||||
exports.APPEAL_CREATOR_REVIEW = 'appeal/review';
|
||||
exports.GENERAL = 'general';
|
||||
exports.LIST = 'list';
|
||||
|
|
12
ui/page/appealListOffences/index.js
Normal file
12
ui/page/appealListOffences/index.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
|
||||
import AppealListOffences from './view';
|
||||
|
||||
const select = (state) => ({});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(AppealListOffences);
|
60
ui/page/appealListOffences/view.jsx
Normal file
60
ui/page/appealListOffences/view.jsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import ClaimList from 'component/claimList';
|
||||
import Page from 'component/page';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
|
||||
type Props = {
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
export default function AppealListOffences(props: Props) {
|
||||
const { doOpenModal } = props;
|
||||
|
||||
const blockerUris = [
|
||||
'lbry://@grumpy#3c98c9fc7988a1de4ac4ccfddab47416d9871c0d',
|
||||
'lbry://@timeout#8237fcdf9c3288d49cff1a3a609d680d486447ff',
|
||||
];
|
||||
|
||||
return (
|
||||
<Page noSideNavigation className="main--half-width" backout={{ backoutLabel: __('Cancel'), title: __('Offences') }}>
|
||||
<div className="help--notice">{HELP.OFFENCES}</div>
|
||||
<div className="section">
|
||||
<ClaimList
|
||||
uris={blockerUris}
|
||||
hideMenu
|
||||
renderProperties={() => null}
|
||||
renderActions={(claim) => {
|
||||
return (
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="secondary"
|
||||
icon={ICONS.APPEAL}
|
||||
label={__('Appeal')}
|
||||
title={__(HELP.MANUAL_APPEAL)}
|
||||
onClick={() => doOpenModal(MODALS.CONFIRM, { title: 'Not implemented yet.' })}
|
||||
/>
|
||||
<Button
|
||||
button="secondary"
|
||||
icon={ICONS.LBC}
|
||||
label={__('Auto Appeal')}
|
||||
title={__(HELP.AUTO_APPEAL)}
|
||||
onClick={() => doOpenModal(MODALS.CONFIRM, { title: 'Not implemented yet.' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
const HELP = {
|
||||
OFFENCES: 'These are creators that have blocked you. You can choose to appeal to the creator. If the creator has enabled automatic appeal (a.k.a swear jar), you can have your offences automatically pardoned by tipping the creator.',
|
||||
MANUAL_APPEAL: 'Appeal to the creator to remove you from their blocklist.',
|
||||
AUTO_APPEAL: 'The creator has enabled the auto-appeal process, where your offence can be automatically pardoned by tipping the creator.',
|
||||
};
|
12
ui/page/appealReview/index.js
Normal file
12
ui/page/appealReview/index.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
|
||||
import AppealReview from './view';
|
||||
|
||||
const select = (state) => ({});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(AppealReview);
|
60
ui/page/appealReview/view.jsx
Normal file
60
ui/page/appealReview/view.jsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import ClaimList from 'component/claimList';
|
||||
import Page from 'component/page';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
|
||||
type Props = {
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
};
|
||||
|
||||
export default function AppealReview(props: Props) {
|
||||
const { doOpenModal } = props;
|
||||
|
||||
const appellantUris = [
|
||||
'lbry://@grumpy#3c98c9fc7988a1de4ac4ccfddab47416d9871c0d',
|
||||
'lbry://@timeout#8237fcdf9c3288d49cff1a3a609d680d486447ff',
|
||||
];
|
||||
|
||||
return (
|
||||
<Page
|
||||
noSideNavigation
|
||||
className="main--half-width"
|
||||
backout={{ backoutLabel: __('Cancel'), title: __('Review Appeals') }}
|
||||
>
|
||||
<div className="help--notice">{HELP.APPELLANTS}</div>
|
||||
<div className="section">
|
||||
<ClaimList
|
||||
uris={appellantUris}
|
||||
hideMenu
|
||||
renderProperties={() => null}
|
||||
renderActions={(claim) => {
|
||||
return (
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="secondary"
|
||||
icon={ICONS.COMPLETE}
|
||||
label={__('Approve')}
|
||||
onClick={() => doOpenModal(MODALS.CONFIRM, { title: 'Not implemented -- waiting for API' })}
|
||||
/>
|
||||
<Button
|
||||
button="secondary"
|
||||
icon={ICONS.REMOVE}
|
||||
label={__('Reject')}
|
||||
onClick={() => doOpenModal(MODALS.CONFIRM, { title: 'Not implemented -- waiting for API' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
const HELP = {
|
||||
APPELLANTS: 'The following users have requested an appeal against blocking their channel.',
|
||||
};
|
|
@ -6,6 +6,7 @@ import classnames from 'classnames';
|
|||
import moment from 'moment';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import BlockList from 'component/blockList';
|
||||
import BlockListShared from 'component/blockListShared';
|
||||
import ClaimPreview from 'component/claimPreview';
|
||||
import Page from 'component/page';
|
||||
import Spinner from 'component/spinner';
|
||||
|
@ -17,6 +18,7 @@ import ChannelMuteButton from 'component/channelMuteButton';
|
|||
const VIEW = {
|
||||
BLOCKED: 'blocked',
|
||||
ADMIN: 'admin',
|
||||
SHARED: 'shared',
|
||||
MODERATOR: 'moderator',
|
||||
MUTED: 'muted',
|
||||
};
|
||||
|
@ -266,10 +268,14 @@ function ListBlocked(props: Props) {
|
|||
{isAdmin && getViewElem(VIEW.ADMIN, 'Global', ICONS.BLOCK)}
|
||||
{isModerator && getViewElem(VIEW.MODERATOR, 'Moderator', ICONS.BLOCK)}
|
||||
{getViewElem(VIEW.MUTED, 'Muted', ICONS.MUTE)}
|
||||
{getViewElem(VIEW.SHARED, 'Shared Blocklist', ICONS.SHARE)}
|
||||
</div>
|
||||
<div className="section__actions--inline">{getRefreshElem()}</div>
|
||||
</div>
|
||||
|
||||
{viewMode === VIEW.SHARED && <BlockListShared />}
|
||||
|
||||
{viewMode !== VIEW.SHARED && (
|
||||
<BlockList
|
||||
key={viewMode}
|
||||
uris={getList(viewMode)}
|
||||
|
@ -279,6 +285,7 @@ function ListBlocked(props: Props) {
|
|||
getActionButtons={getActionButtons}
|
||||
className={viewMode === VIEW.MODERATOR ? 'block-list--moderator' : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
|
|
17
ui/page/sharedBlocklistEdit/index.js
Normal file
17
ui/page/sharedBlocklistEdit/index.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doSblUpdate } from 'redux/actions/comments';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
|
||||
import SharedBlocklistEdit from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doSblUpdate: (channelClaim, params, onComplete) => dispatch(doSblUpdate(channelClaim, params, onComplete)),
|
||||
doToast: (options) => dispatch(doToast(options)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(SharedBlocklistEdit);
|
206
ui/page/sharedBlocklistEdit/view.jsx
Normal file
206
ui/page/sharedBlocklistEdit/view.jsx
Normal file
|
@ -0,0 +1,206 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import Button from 'component/button';
|
||||
import ChannelSelector from 'component/channelSelector';
|
||||
import Card from 'component/common/card';
|
||||
import { Form, FormField } from 'component/common/form';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import Page from 'component/page';
|
||||
import Spinner from 'component/spinner';
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
|
||||
const DEFAULT_STRIKE_HOURS = 4;
|
||||
|
||||
type Props = {
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
doSblUpdate: (channelClaim: ChannelClaim, params: SblUpdate, onComplete: (error: string) => void) => void,
|
||||
doToast: ({ message: string }) => void,
|
||||
};
|
||||
|
||||
export default function SharedBlocklistEdit(props: Props) {
|
||||
const { activeChannelClaim, doSblUpdate, doToast } = props;
|
||||
|
||||
const { goBack, location } = useHistory();
|
||||
|
||||
const sbl = location.state;
|
||||
const [name, setName] = React.useState('');
|
||||
const [category, setCategory] = React.useState('');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [strike1, setStrike1] = React.useState(DEFAULT_STRIKE_HOURS);
|
||||
const [strike2, setStrike2] = React.useState(DEFAULT_STRIKE_HOURS);
|
||||
const [strike3, setStrike3] = React.useState(DEFAULT_STRIKE_HOURS);
|
||||
const [appealAmount, setAppealAmount] = React.useState(0);
|
||||
const [memberInviteEnabled, setMemberInviteEnabled] = usePersistedState('sblEdit:memberInviteEnabled', true);
|
||||
const [inviteExpiration, setInviteExpiration] = usePersistedState('sblEdit:inviteExpiration', 0);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
|
||||
const submitDisabled = !name || !description;
|
||||
|
||||
function submitForm() {
|
||||
const params: SblUpdate = {
|
||||
name,
|
||||
category,
|
||||
description,
|
||||
strike_one: strike1,
|
||||
strike_two: strike2,
|
||||
strike_three: strike3,
|
||||
curse_jar_amount: appealAmount,
|
||||
member_invite_enabled: memberInviteEnabled,
|
||||
invite_expiration: inviteExpiration,
|
||||
};
|
||||
|
||||
if (activeChannelClaim) {
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
doSblUpdate(activeChannelClaim, params, handleUpdateCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateCompleted(error: string) {
|
||||
setSubmitting(false);
|
||||
if (error) {
|
||||
setError(error);
|
||||
} else {
|
||||
doToast({ message: __('Shared blocklist updated.') });
|
||||
goBack();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload data from existing sbl
|
||||
React.useEffect(() => {
|
||||
if (sbl) {
|
||||
setName(sbl.name);
|
||||
setCategory(sbl.category);
|
||||
setDescription(sbl.description);
|
||||
setStrike1(sbl.strike_one);
|
||||
setStrike2(sbl.strike_two);
|
||||
setStrike3(sbl.strike_three);
|
||||
setAppealAmount(sbl.curse_jar_amount);
|
||||
setMemberInviteEnabled(sbl.member_invite_enabled);
|
||||
setInviteExpiration(sbl.invite_expiration);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Page
|
||||
noSideNavigation
|
||||
className="main--half-width"
|
||||
backout={{
|
||||
backoutLabel: __('Cancel'),
|
||||
title: sbl ? __('Edit shared blocklist') : __('Create shared blocklist'),
|
||||
}}
|
||||
>
|
||||
<Form onSubmit={submitForm}>
|
||||
<Card
|
||||
className={submitting ? 'sbl-edit card--disabled' : 'sbl-edit '}
|
||||
body={
|
||||
<>
|
||||
<ChannelSelector hideAnon />
|
||||
<FormField
|
||||
label={__('Blocklist name')}
|
||||
type="text"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
label={__('Category')}
|
||||
type="text"
|
||||
name="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
label={__('Description')}
|
||||
type="textarea"
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
textAreaMaxLength={1000}
|
||||
noEmojis
|
||||
/>
|
||||
<fieldset-section>
|
||||
<label>{__('Timeout (hours)')}</label>
|
||||
<div className="section section__flex">
|
||||
<FormField
|
||||
label={__('Strike 1')}
|
||||
placeholder={__('Strike 1 timeout in hours')}
|
||||
type="number"
|
||||
name="strike_one"
|
||||
min={0}
|
||||
step={1}
|
||||
value={strike1}
|
||||
onChange={(e) => setStrike1(parseInt(e.target.value))}
|
||||
/>
|
||||
<FormField
|
||||
label={__('Strike 2')}
|
||||
placeholder={__('Strike 2 timeout in hours')}
|
||||
type="number"
|
||||
name="strike_two"
|
||||
min={0}
|
||||
step={1}
|
||||
value={strike2}
|
||||
onChange={(e) => setStrike2(parseInt(e.target.value))}
|
||||
/>
|
||||
<FormField
|
||||
label={__('Strike 3')}
|
||||
placeholder={__('Strike 3 timeout in hours')}
|
||||
type="number"
|
||||
name="strike_three"
|
||||
min={0}
|
||||
step={1}
|
||||
value={strike3}
|
||||
onChange={(e) => setStrike3(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</fieldset-section>
|
||||
<FormField
|
||||
label={<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Auto-appeal minimum %lbc%</I18nMessage>}
|
||||
name="curse_jar_amount"
|
||||
className="form-field--price-amount"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
value={appealAmount}
|
||||
onChange={(e) => setAppealAmount(parseFloat(e.target.value))}
|
||||
/>
|
||||
<FormField
|
||||
label={__('Invite expiration (hours)')}
|
||||
placeholder={__('Set to 0 for no expiration.')}
|
||||
type="number"
|
||||
name="invite_expiration"
|
||||
min={0}
|
||||
step={1}
|
||||
value={inviteExpiration}
|
||||
onChange={(e) => setInviteExpiration(parseInt(e.target.value))}
|
||||
/>
|
||||
<FormField
|
||||
label={__('Allow members to invite others')}
|
||||
type="checkbox"
|
||||
name="member_invite_enabled"
|
||||
checked={memberInviteEnabled}
|
||||
onChange={() => setMemberInviteEnabled(!memberInviteEnabled)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
label={submitting ? <Spinner type="small" /> : __('Done')}
|
||||
button="primary"
|
||||
type="submit"
|
||||
disabled={submitDisabled}
|
||||
/>
|
||||
<Button button="link" label={__('Cancel')} onClick={() => goBack()} />
|
||||
</div>
|
||||
{error && <div className="help--error">{__(error)}</div>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Page>
|
||||
);
|
||||
}
|
17
ui/page/sharedBlocklistInvite/index.js
Normal file
17
ui/page/sharedBlocklistInvite/index.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doSblInvite } from 'redux/actions/comments';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
|
||||
import SharedBlocklistInvite from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
activeChannelClaim: selectActiveChannelClaim(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doSblInvite: (channelClaim, paramList, onComplete) => dispatch(doSblInvite(channelClaim, paramList, onComplete)),
|
||||
doToast: (options) => dispatch(doToast(options)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(SharedBlocklistInvite);
|
125
ui/page/sharedBlocklistInvite/view.jsx
Normal file
125
ui/page/sharedBlocklistInvite/view.jsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import Button from 'component/button';
|
||||
import Card from 'component/common/card';
|
||||
import { Form, FormField } from 'component/common/form';
|
||||
import Page from 'component/page';
|
||||
import SearchChannelField from 'component/searchChannelField';
|
||||
import Spinner from 'component/spinner';
|
||||
|
||||
const PARAM_BLOCKLIST_ID = 'id';
|
||||
|
||||
type Props = {
|
||||
activeChannelClaim: ?ChannelClaim,
|
||||
doSblInvite: (channelClaim: ChannelClaim, paramList: Array<SblInvite>, onComplete: (err: string) => void) => void,
|
||||
doToast: ({ message: string }) => void,
|
||||
};
|
||||
|
||||
export default function SharedBlocklistInvite(props: Props) {
|
||||
const { activeChannelClaim, doSblInvite, doToast } = props;
|
||||
|
||||
const { goBack, location } = useHistory();
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const id = urlParams.get(PARAM_BLOCKLIST_ID);
|
||||
|
||||
const [invitees, setInvitees] = React.useState([]);
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
|
||||
const isInputValid = activeChannelClaim && invitees && invitees.length !== 0;
|
||||
|
||||
function submitForm() {
|
||||
if (activeChannelClaim && isInputValid) {
|
||||
const paramList = invitees.map((invitee) => {
|
||||
const inviteeInfo = invitee.split('#');
|
||||
const params: SblInvite = {
|
||||
blocked_list_id: id ? parseInt(id) : undefined,
|
||||
invitee_channel_name: inviteeInfo[0],
|
||||
invitee_channel_id: inviteeInfo[1],
|
||||
message,
|
||||
};
|
||||
return params;
|
||||
});
|
||||
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
doSblInvite(activeChannelClaim, paramList, handleInviteCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInviteCompleted(error: string) {
|
||||
setSubmitting(false);
|
||||
if (error) {
|
||||
setError(error);
|
||||
} else {
|
||||
const multipleInvitees = invitees && invitees.length > 1;
|
||||
doToast({ message: multipleInvitees ? __('Invites sent.') : __('Invite sent.') });
|
||||
goBack();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInviteeAdded(channelUri: string) {
|
||||
setInvitees([...invitees, channelUri]);
|
||||
}
|
||||
|
||||
function handleInviteeRemoved(channelUri: string) {
|
||||
setInvitees(invitees.filter((x) => x !== channelUri));
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
noSideNavigation
|
||||
className="main--half-width"
|
||||
backout={{
|
||||
backoutLabel: __('Cancel'),
|
||||
title: __('Invite'),
|
||||
}}
|
||||
>
|
||||
<Form onSubmit={submitForm}>
|
||||
<Card
|
||||
title={__('Invite others to use and contribute to this blocklist.')}
|
||||
subtitle={' '}
|
||||
className={submitting ? 'card--disabled' : ''}
|
||||
body={
|
||||
<>
|
||||
<SearchChannelField
|
||||
label={__('Invitees')}
|
||||
labelAddNew={__('Add invitee')}
|
||||
labelFoundAction={__('Add')}
|
||||
values={invitees}
|
||||
onAdd={handleInviteeAdded}
|
||||
onRemove={handleInviteeRemoved}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label={__('Message')}
|
||||
type="textarea"
|
||||
name="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
textAreaMaxLength={1000}
|
||||
noEmojis
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
label={submitting ? <Spinner type="small" /> : __('Send')}
|
||||
disabled={!isInputValid}
|
||||
button="primary"
|
||||
type="submit"
|
||||
/>
|
||||
<Button label={__('Cancel')} button="link" onClick={() => goBack()} />
|
||||
</div>
|
||||
{error && <div className="help--error">{__(error)}</div>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Page>
|
||||
);
|
||||
}
|
|
@ -49,6 +49,8 @@ const ERR_MAP: CommentronErrorMap = {
|
|||
BLOCKED_BY_CREATOR: {
|
||||
commentron: 'channel is blocked by publisher',
|
||||
replacement: 'Unable to comment. This channel has blocked you.',
|
||||
linkText: 'Appeal',
|
||||
linkTarget: `/${PAGES.APPEAL_LIST_OFFENCES}`,
|
||||
},
|
||||
BLOCKED_BY_ADMIN: {
|
||||
commentron: 'channel is not allowed to post comments',
|
||||
|
@ -62,6 +64,12 @@ const ERR_MAP: CommentronErrorMap = {
|
|||
commentron: 'duplicate comment!',
|
||||
replacement: 'Please do not spam.',
|
||||
},
|
||||
TIMEOUT_BANNED: {
|
||||
commentron: /^publisher (.*) has given you a temporary ban with (.*) remaining.$/,
|
||||
replacement: '%1% has given you a temporary ban with %2% remaining.', // TODO: The duration is not localizable.
|
||||
linkText: 'Appeal',
|
||||
linkTarget: `/${PAGES.APPEAL_LIST_OFFENCES}`,
|
||||
},
|
||||
};
|
||||
|
||||
function devToast(dispatch, msg) {
|
||||
|
@ -1624,3 +1632,245 @@ export const doFetchBlockedWords = () => {
|
|||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblUpdate = (channelClaim: ChannelClaim, params: SblUpdate, onComplete: (error: string) => void) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'doSblUpdate: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
|
||||
return Comments.sbl_update({
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
...params,
|
||||
})
|
||||
.then(() => {
|
||||
if (onComplete) {
|
||||
onComplete('');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
if (onComplete) {
|
||||
onComplete(err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblDelete = (channelClaim: ChannelClaim) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'doSblDelete: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
|
||||
return Comments.sbl_update({
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
remove: true,
|
||||
})
|
||||
.then((resp) => {
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_DELETED,
|
||||
});
|
||||
dispatch(doToast({ message: __('Shared blocklist deleted.') }));
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblGet = (channelClaim: ?ChannelClaim, params: SblGet) => {
|
||||
return async (dispatch: Dispatch, getState: GetState) => {
|
||||
let channelSignature;
|
||||
if (channelClaim) {
|
||||
channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'doSblGet: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_FETCH_STARTED,
|
||||
});
|
||||
|
||||
const authParams =
|
||||
channelClaim && channelSignature
|
||||
? {
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
}
|
||||
: {};
|
||||
|
||||
return Comments.sbl_get({ ...authParams, ...params })
|
||||
.then((resp) => {
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_FETCH_COMPLETED,
|
||||
data: resp,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.message !== 'blocked list not found') {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_FETCH_FAILED,
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblInvite = (
|
||||
channelClaim: ChannelClaim,
|
||||
paramList: Array<SblInvite>,
|
||||
onComplete: (error: string) => void
|
||||
) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'doSblInvite: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
|
||||
// $FlowFixMe
|
||||
return Promise.allSettled(
|
||||
paramList.map((params) => {
|
||||
return Comments.sbl_invite({
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
...params,
|
||||
});
|
||||
})
|
||||
)
|
||||
.then((results) => {
|
||||
const errors: Array<string> = [];
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'rejected') {
|
||||
errors.push(result.reason.message);
|
||||
}
|
||||
});
|
||||
if (onComplete) {
|
||||
onComplete(errors.join(' • '));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
if (onComplete) {
|
||||
onComplete(err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblListInvites = (channelClaim: ChannelClaim) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'SBL Invite: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_FETCH_INVITES_STARTED,
|
||||
});
|
||||
|
||||
return Comments.sbl_list_invites({
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
})
|
||||
.then((resp) => {
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_FETCH_INVITES_COMPLETED,
|
||||
data: resp,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
dispatch({
|
||||
type: ACTIONS.COMMENT_SBL_FETCH_INVITES_FAILED,
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblAccept = (
|
||||
channelClaim: ChannelClaim,
|
||||
params: SblInviteAccept,
|
||||
onComplete: (error: string) => void
|
||||
) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'doSblAccept: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
|
||||
return Comments.sbl_accept({
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
...params,
|
||||
})
|
||||
.then((resp) => {
|
||||
if (onComplete) {
|
||||
onComplete('');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
if (onComplete) {
|
||||
onComplete(err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const doSblRescind = (channelClaim: ChannelClaim, params: SblRescind, onComplete: (error: string) => void) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
|
||||
if (!channelSignature) {
|
||||
devToast(dispatch, 'doSblRescind: failed to sign channel name');
|
||||
return;
|
||||
}
|
||||
|
||||
return Comments.sbl_rescind({
|
||||
channel_name: channelClaim.name,
|
||||
channel_id: channelClaim.claim_id,
|
||||
signature: channelSignature.signature,
|
||||
signing_ts: channelSignature.signing_ts,
|
||||
...params,
|
||||
})
|
||||
.then((resp) => {
|
||||
if (onComplete) {
|
||||
onComplete('');
|
||||
}
|
||||
dispatch(
|
||||
doToast({ message: __('Membership for %member% rescinded.', { member: params.invited_channel_name }) })
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(doToast({ message: err.message, isError: true }));
|
||||
if (onComplete) {
|
||||
onComplete(err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
@ -48,6 +48,10 @@ const defaultState: CommentsState = {
|
|||
settingsByChannelId: {}, // ChannelId -> PerChannelSettings
|
||||
fetchingSettings: false,
|
||||
fetchingBlockedWords: false,
|
||||
sblMine: undefined,
|
||||
sblInvited: [],
|
||||
fetchingSblMine: false,
|
||||
fetchingSblInvited: false,
|
||||
};
|
||||
|
||||
function pushToArrayInObject(obj, key, valueToPush) {
|
||||
|
@ -1072,6 +1076,45 @@ export default handleActions(
|
|||
fetchingBlockedWords: false,
|
||||
};
|
||||
},
|
||||
|
||||
[ACTIONS.COMMENT_SBL_FETCH_STARTED]: (state: CommentsState) => ({
|
||||
...state,
|
||||
fetchingSblMine: true,
|
||||
}),
|
||||
[ACTIONS.COMMENT_SBL_FETCH_FAILED]: (state: CommentsState) => ({
|
||||
...state,
|
||||
sblMine: null,
|
||||
fetchingSblMine: false,
|
||||
}),
|
||||
[ACTIONS.COMMENT_SBL_FETCH_COMPLETED]: (state: CommentsState, action: any) => {
|
||||
return {
|
||||
...state,
|
||||
sblMine: action.data,
|
||||
fetchingSblMine: false,
|
||||
};
|
||||
},
|
||||
|
||||
[ACTIONS.COMMENT_SBL_FETCH_INVITES_STARTED]: (state: CommentsState) => ({
|
||||
...state,
|
||||
fetchingSblInvited: true,
|
||||
}),
|
||||
[ACTIONS.COMMENT_SBL_FETCH_INVITES_FAILED]: (state: CommentsState) => ({
|
||||
...state,
|
||||
sblInvited: null,
|
||||
fetchingSblInvited: false,
|
||||
}),
|
||||
[ACTIONS.COMMENT_SBL_FETCH_INVITES_COMPLETED]: (state: CommentsState, action: any) => {
|
||||
return {
|
||||
...state,
|
||||
sblInvited: action.data.invitations,
|
||||
fetchingSblInvited: false,
|
||||
};
|
||||
},
|
||||
|
||||
[ACTIONS.COMMENT_SBL_DELETED]: (state: CommentsState) => ({
|
||||
...state,
|
||||
sblMine: null,
|
||||
}),
|
||||
},
|
||||
defaultState
|
||||
);
|
||||
|
|
|
@ -406,3 +406,8 @@ export const makeSelectSuperChatTotalAmountForUri = (uri: string) =>
|
|||
|
||||
return superChatData.totalAmount;
|
||||
});
|
||||
|
||||
export const selectFetchingSblMine = createSelector(selectState, (state) => state.fetchingSblMine);
|
||||
export const selectFetchingSblInvited = createSelector(selectState, (state) => state.fetchingSblInvited);
|
||||
export const selectSblMine = createSelector(selectState, (state) => state.sblMine);
|
||||
export const selectSblInvited = createSelector(selectState, (state) => state.sblInvited);
|
||||
|
|
|
@ -83,3 +83,55 @@
|
|||
white-space: pre-line;
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
|
||||
.sbl-edit {
|
||||
.section__flex {
|
||||
fieldset-section {
|
||||
margin-top: 0;
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sbl {
|
||||
.table {
|
||||
width: 95%;
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
}
|
||||
|
||||
.sbl-info {
|
||||
.button--link {
|
||||
margin-top: var(--spacing-m);
|
||||
width: 100%;
|
||||
|
||||
.button__content {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sbl_invite_expired {
|
||||
align-self: flex-end;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.sbl_invite_accepted {
|
||||
align-self: flex-end;
|
||||
color: #2bbb90;
|
||||
}
|
||||
|
||||
.sbl-status--active {
|
||||
font-size: var(--font-small);
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
color: white;
|
||||
background: #2bbb90;
|
||||
}
|
||||
|
||||
.sbl-status--inactive {
|
||||
@extend .sbl-status--active;
|
||||
background: var(--color-gray-5);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,10 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card--disable-interaction {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card--section {
|
||||
position: relative;
|
||||
padding: var(--spacing-m);
|
||||
|
|
|
@ -338,6 +338,11 @@
|
|||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.main--half-width {
|
||||
@extend .main;
|
||||
max-width: calc(var(--page-max-width) / 2);
|
||||
}
|
||||
|
||||
.main--empty {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
|
|
|
@ -275,3 +275,13 @@ td {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table--no-row-lines {
|
||||
tr {
|
||||
&:not(:last-of-type) {
|
||||
td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,3 +3,11 @@
|
|||
export function toCapitalCase(string: string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
export function getHoursStr(hours: number) {
|
||||
return hours <= 0 ? '---' : hours === 1 ? __('1 hour') : __('%value% hours', { value: hours });
|
||||
}
|
||||
|
||||
export function getYesNoStr(state: boolean) {
|
||||
return state ? __('Yes') : __('No');
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue