Shared Blocklist (minus Appeal) #7149

Closed
infinite-persistence wants to merge 4 commits from ip/shared.block.list into master
39 changed files with 1679 additions and 136 deletions

61
flow-typed/Comment.js vendored
View file

@ -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;

View file

@ -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: {}) {

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

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

View file

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

View file

@ -0,0 +1,3 @@
import FormFieldDuration from './view';
export default FormFieldDuration;

View file

@ -0,0 +1,88 @@
// @flow
import type { Node } from 'react';
import React from 'react';
import parseDuration from 'parse-duration';
import { FormField } from 'component/common/form';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
const INPUT_EXAMPLES = '\n- 30s\n- 10m\n- 1h\n- 2d\n- 3mo\n- 1y';
const ONE_HUNDRED_YEARS_IN_SECONDS = 3154000000;
type Props = {
name: string,
label?: string | Node,
placeholder?: string | number,
value: string | number,
onChange: (any) => void,
onResolve: (valueInSeconds: number) => void, // Returns parsed/resolved value in seconds; "-1" for invalid input.
maxDurationInSeconds?: number,
};
export default function FormFieldDuration(props: Props) {
const { name, label, placeholder, value, onChange, onResolve, maxDurationInSeconds } = props;
const [valueSec, setValueSec] = React.useState(-1);
const [valueErr, setValueErr] = React.useState('');
React.useEffect(() => {
const handleInvalidInput = (errMsg: string) => {
if (valueSec !== -1) {
setValueSec(-1);
onResolve(-1);
}
if (valueErr !== errMsg) {
setValueErr(errMsg);
}
};
const handleValidInput = (seconds) => {
if (seconds !== valueSec) {
setValueSec(seconds);
onResolve(seconds);
}
if (valueErr) {
setValueErr('');
}
};
if (!value) {
handleValidInput(-1); // Reset
return;
}
const seconds = parseDuration(value, 's');
if (Number.isInteger(seconds) && seconds > 0) {
const max = maxDurationInSeconds || ONE_HUNDRED_YEARS_IN_SECONDS;
if (seconds > max) {
handleInvalidInput(__('Value exceeded maximum.'));
} else {
handleValidInput(seconds);
}
} else {
handleInvalidInput(__('Invalid duration.'));
}
}, [value, valueSec, valueErr, maxDurationInSeconds, onResolve]);
return (
<FormField
name={name}
type="text"
label={
<>
{label || __('Duration')}
<Icon
customTooltipText={__('Examples: %examples%', { examples: INPUT_EXAMPLES })}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</>
}
placeholder={placeholder || '30s, 10m, 1h, 2d, 3mo, 1y'}
value={value}
onChange={onChange}
error={valueErr}
/>
);
}

View file

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

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import WalletSwap from './view';
import { doOpenModal } from 'redux/actions/app';
import { doAddCoinSwap, doQueryCoinSwapStatus } from 'redux/actions/coinSwap';
import { doAddCoinSwap, doRemoveCoinSwap, doQueryCoinSwapStatus } from 'redux/actions/coinSwap';
import { doToast } from 'redux/actions/notifications';
import { selectCoinSwaps } from 'redux/selectors/coinSwap';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -18,6 +18,7 @@ const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToast: (options) => dispatch(doToast(options)),
addCoinSwap: (coinSwap) => dispatch(doAddCoinSwap(coinSwap)),
removeCoinSwap: (chargeCode) => dispatch(doRemoveCoinSwap(chargeCode)),
getNewAddress: () => dispatch(doGetNewAddress()),
checkAddressIsMine: (address) => dispatch(doCheckAddressIsMine(address)),
queryCoinSwapStatus: (sendAddress) => dispatch(doQueryCoinSwapStatus(sendAddress)),

View file

@ -59,6 +59,7 @@ type Props = {
isAuthenticated: boolean,
doToast: ({ message: string }) => void,
addCoinSwap: (CoinSwapInfo) => void,
removeCoinSwap: (string) => void,
getNewAddress: () => void,
checkAddressIsMine: (string) => void,
openModal: (string, {}) => void,
@ -72,6 +73,7 @@ function WalletSwap(props: Props) {
coinSwaps,
isAuthenticated,
addCoinSwap,
removeCoinSwap,
getNewAddress,
checkAddressIsMine,
openModal,
@ -103,9 +105,15 @@ function WalletSwap(props: Props) {
setSwap(null);
}
function removeCoinSwap(chargeCode) {
openModal(MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS, {
chargeCode: chargeCode,
function handleRemoveSwap(chargeCode) {
openModal(MODALS.CONFIRM, {
title: __('Remove Swap'),
subtitle: <I18nMessage tokens={{ address: <em>{`${chargeCode}`}</em> }}>Remove %address%?</I18nMessage>,
body: <p className="help--warning">{__('This process cannot be reversed.')}</p>,
onConfirm: (closeModal) => {
removeCoinSwap(chargeCode);
closeModal();
},
});
}
@ -657,7 +665,7 @@ function WalletSwap(props: Props) {
button="link"
icon={ICONS.REMOVE}
title={__('Remove swap')}
onClick={() => removeCoinSwap(x.chargeCode)}
onClick={() => handleRemoveSwap(x.chargeCode)}
/>
</td>
</tr>

View file

@ -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';

View file

@ -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,
};

View file

@ -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';
jessopb commented 2021-10-05 06:26:50 +02:00 (Migrated from github.com)
Review

I like this USAGE = 'description' pattern.

I like this USAGE = 'description' pattern.
export const REPLAY = 'Replay';
export const REPEAT = 'Repeat';
export const SHUFFLE = 'Shuffle';

View file

@ -1,3 +1,4 @@
export const CONFIRM = 'confirm';
export const CONFIRM_FILE_REMOVE = 'confirm_file_remove';
export const CONFIRM_EXTERNAL_RESOURCE = 'confirm_external_resource';
export const COMMENT_ACKNOWEDGEMENT = 'comment_acknowlegement';
@ -42,7 +43,6 @@ export const SYNC_ENABLE = 'SYNC_ENABLE';
export const IMAGE_UPLOAD = 'image_upload';
export const MOBILE_SEARCH = 'mobile_search';
export const VIEW_IMAGE = 'view_image';
export const CONFIRM_REMOVE_BTC_SWAP_ADDRESS = 'confirm_remove_btc_swap_address';
export const BLOCK_CHANNEL = 'block_channel';
export const COLLECTION_ADD = 'collection_add';
export const COLLECTION_DELETE = 'collection_delete';

View file

@ -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';

View file

@ -1,14 +1,12 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import parseDuration from 'parse-duration';
import Button from 'component/button';
import ChannelThumbnail from 'component/channelThumbnail';
import ClaimPreview from 'component/claimPreview';
import Card from 'component/common/card';
import { FormField } from 'component/common/form';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import FormFieldDuration from 'component/formFieldDuration';
import usePersistedState from 'effects/use-persisted-state';
import { Modal } from 'modal/modal';
import { getChannelFromClaim } from 'util/claim';
@ -67,7 +65,6 @@ export default function ModalBlockChannel(props: Props) {
const [tab, setTab] = usePersistedState('ModalBlockChannel:tab', TAB.PERSONAL);
const [blockType, setBlockType] = usePersistedState('ModalBlockChannel:blockType', BLOCK.PERMANENT);
const [timeoutInput, setTimeoutInput] = usePersistedState('ModalBlockChannel:timeoutInput', '10m');
const [timeoutInputErr, setTimeoutInputErr] = React.useState('');
const [timeoutSec, setTimeoutSec] = React.useState(-1);
const isPersonalTheOnlyTab = !activeChannelIsModerator && !activeChannelIsAdmin;
@ -92,45 +89,6 @@ export default function ModalBlockChannel(props: Props) {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 'timeoutInput' to 'timeoutSec' conversion.
React.useEffect(() => {
const handleInvalidInput = (errMsg: string) => {
if (timeoutSec !== -1) {
setTimeoutSec(-1);
}
if (timeoutInputErr !== errMsg) {
setTimeoutInputErr(errMsg);
}
};
const handleValidInput = (seconds) => {
if (seconds !== timeoutSec) {
setTimeoutSec(seconds);
}
if (timeoutInputErr) {
setTimeoutInputErr('');
}
};
if (!timeoutInput) {
handleValidInput(-1); // Reset
return;
}
const ONE_HUNDRED_YEARS_IN_SECONDS = 3154000000;
const seconds = parseDuration(timeoutInput, 's');
if (Number.isInteger(seconds) && seconds > 0) {
if (seconds > ONE_HUNDRED_YEARS_IN_SECONDS) {
handleInvalidInput(__('Wow, banned for more than 100 years?'));
} else {
handleValidInput(seconds);
}
} else {
handleInvalidInput(__('Invalid duration.'));
}
}, [timeoutInput, timeoutInputErr, timeoutSec]);
// **************************************************************************
// **************************************************************************
@ -178,27 +136,12 @@ export default function ModalBlockChannel(props: Props) {
}
function getTimeoutDurationElem() {
const examples = '\n- 30s\n- 10m\n- 1h\n- 2d\n- 3mo\n- 1y';
return (
<FormField
<FormFieldDuration
name="time_out"
label={
<>
{__('Duration')}
<Icon
customTooltipText={__('Enter the timeout duration. Examples: %examples%', { examples })}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</>
}
type="text"
placeholder="30s, 10m, 1h, 2d, 3mo, 1y"
value={timeoutInput}
onChange={(e) => setTimeoutInput(e.target.value)}
error={timeoutInputErr}
onResolve={(valueInSeconds) => setTimeoutSec(valueInSeconds)}
/>
);
}

View file

@ -0,0 +1,10 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import ModalConfirm from './view';
const perform = (dispatch) => ({
doHideModal: () => dispatch(doHideModal()),
});
export default connect(null, perform)(ModalConfirm);

View file

@ -0,0 +1,55 @@
// @flow
import React from 'react';
import type { Node } from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import Spinner from 'component/spinner';
import { Modal } from 'modal/modal';
type Props = {
title: string,
subtitle?: string | Node,
body?: string | Node,
labelOk?: string,
labelCancel?: string,
onConfirm: (closeModal: () => void, setIsBusy: (boolean) => void) => void,
// --- perform ---
doHideModal: () => void,
};
export default function ModalConfirm(props: Props) {
const { title, subtitle, body, labelOk, labelCancel, onConfirm, doHideModal } = props;
const [isBusy, setIsBusy] = React.useState(false);
function handleOnClick() {
if (onConfirm) {
onConfirm(doHideModal, setIsBusy);
}
}
function getOkLabel() {
return isBusy ? <Spinner type="small" /> : labelOk || __('OK');
}
function getCancelLabel() {
return labelCancel || __('Cancel');
}
return (
<Modal isOpen type="card" onAborted={doHideModal}>
<Card
title={title}
subtitle={subtitle}
body={body}
actions={
<>
<div className="section__actions">
<Button button="primary" label={getOkLabel()} disabled={isBusy} onClick={handleOnClick} />
<Button button="link" label={getCancelLabel()} disabled={isBusy} onClick={doHideModal} />
</div>
</>
}
/>
</Modal>
);
}

View file

@ -1,13 +0,0 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import ModalRemoveBtcSwapAddress from './view';
import { doRemoveCoinSwap } from 'redux/actions/coinSwap';
const select = (state, props) => ({});
const perform = (dispatch) => ({
removeCoinSwap: (chargeCode) => dispatch(doRemoveCoinSwap(chargeCode)),
closeModal: () => dispatch(doHideModal()),
});
export default connect(select, perform)(ModalRemoveBtcSwapAddress);

View file

@ -1,43 +0,0 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import Button from 'component/button';
import Card from 'component/common/card';
import I18nMessage from 'component/i18nMessage';
type Props = {
chargeCode: string,
removeCoinSwap: (string) => void,
closeModal: () => void,
};
function ModalRemoveBtcSwapAddress(props: Props) {
const { chargeCode, removeCoinSwap, closeModal } = props;
return (
<Modal isOpen contentLabel={__('Confirm Swap Removal')} type="card" onAborted={closeModal}>
<Card
title={__('Remove Swap')}
subtitle={<I18nMessage tokens={{ address: <em>{`${chargeCode}`}</em> }}>Remove %address%?</I18nMessage>}
body={<p className="help--warning">{__('This process cannot be reversed.')}</p>}
actions={
<>
<div className="section__actions">
<Button
button="primary"
label={__('OK')}
onClick={() => {
removeCoinSwap(chargeCode);
closeModal();
}}
/>
<Button button="link" label={__('Cancel')} onClick={closeModal} />
</div>
</>
}
/>
</Modal>
);
}
export default ModalRemoveBtcSwapAddress;

View file

@ -23,6 +23,7 @@ const ModalClaimCollectionAdd = lazyImport(() =>
const ModalCommentAcknowledgement = lazyImport(() =>
import('modal/modalCommentAcknowledgement' /* webpackChunkName: "modalCommentAcknowledgement" */)
);
const ModalConfirm = lazyImport(() => import('modal/modalConfirm' /* webpackChunkName: "modalConfirm" */));
const ModalConfirmAge = lazyImport(() => import('modal/modalConfirmAge' /* webpackChunkName: "modalConfirmAge" */));
const ModalConfirmThumbnailUpload = lazyImport(() =>
import('modal/modalConfirmThumbnailUpload' /* webpackChunkName: "modalConfirmThumbnailUpload" */)
@ -63,9 +64,6 @@ const ModalPublish = lazyImport(() => import('modal/modalPublish' /* webpackChun
const ModalPublishPreview = lazyImport(() =>
import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */)
);
const ModalRemoveBtcSwapAddress = lazyImport(() =>
import('modal/modalRemoveBtcSwapAddress' /* webpackChunkName: "modalRemoveBtcSwapAddress" */)
);
const ModalRemoveCard = lazyImport(() => import('modal/modalRemoveCard' /* webpackChunkName: "modalRemoveCard" */));
const ModalRemoveComment = lazyImport(() =>
import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */)
@ -124,6 +122,8 @@ function ModalRouter(props: Props) {
function getModal(id) {
switch (id) {
case MODALS.CONFIRM:
return ModalConfirm;
case MODALS.UPGRADE:
return ModalUpgrade;
case MODALS.DOWNLOADING:
@ -198,8 +198,6 @@ function ModalRouter(props: Props) {
return ModalViewImage;
case MODALS.MASS_TIP_UNLOCK:
return ModalMassTipsUnlock;
case MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS:
return ModalRemoveBtcSwapAddress;
case MODALS.BLOCK_CHANNEL:
return ModalBlockChannel;
case MODALS.COLLECTION_ADD:

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

View 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 = [
jessopb commented 2021-10-05 06:30:38 +02:00 (Migrated from github.com)
Review

What are these and why are they hardcoded.. needs comment?

What are these and why are they hardcoded.. needs comment?
jessopb commented 2021-10-05 06:31:06 +02:00 (Migrated from github.com)
Review

Oh, ,"minus appeal".

Oh, ,"minus appeal".
'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.',
};

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

View 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.',
};

View file

@ -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,19 +268,24 @@ 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>
<BlockList
key={viewMode}
uris={getList(viewMode)}
help={getHelpText(viewMode)}
titleEmptyList={getEmptyListTitle(viewMode)}
subtitle={getEmptyListSubtitle(viewMode)}
getActionButtons={getActionButtons}
className={viewMode === VIEW.MODERATOR ? 'block-list--moderator' : undefined}
/>
{viewMode === VIEW.SHARED && <BlockListShared />}
{viewMode !== VIEW.SHARED && (
<BlockList
key={viewMode}
uris={getList(viewMode)}
help={getHelpText(viewMode)}
titleEmptyList={getEmptyListTitle(viewMode)}
subtitle={getEmptyListSubtitle(viewMode)}
getActionButtons={getActionButtons}
className={viewMode === VIEW.MODERATOR ? 'block-list--moderator' : undefined}
/>
)}
</>
)}
</Page>

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

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

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

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

View file

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

View file

@ -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
);

View file

@ -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);

View file

@ -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;
jessopb commented 2021-10-05 06:35:51 +02:00 (Migrated from github.com)
Review

no var for this color?

no var for this color?
infinite-persistence commented 2021-10-05 10:24:25 +02:00 (Migrated from github.com)
Review

Oops, slipped.

Oops, slipped.
}
.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);
}

View file

@ -20,6 +20,10 @@
pointer-events: none;
}
.card--disable-interaction {
pointer-events: none;
}
.card--section {
position: relative;
padding: var(--spacing-m);

View file

@ -454,6 +454,10 @@ fieldset-group {
margin-bottom: var(--spacing-s);
}
.form-field__textarea-info:empty {
display: none;
}
.form-field__quick-emojis {
> *:not(:last-child) {
margin-right: var(--spacing-s);

View file

@ -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;

View file

@ -275,3 +275,13 @@ td {
}
}
}
.table--no-row-lines {
tr {
&:not(:last-of-type) {
td {
border-bottom: none;
}
}
}
}

View file

@ -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');
}