This commit is contained in:
infinite-persistence 2021-08-23 17:52:08 +08:00
parent 6965c4b99c
commit a34aa6969f
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
27 changed files with 1498 additions and 9 deletions

61
flow-typed/Comment.js vendored
View file

@ -20,6 +20,13 @@ declare type Comment = {
is_fiat?: boolean, is_fiat?: boolean,
}; };
declare type CommentronAuth = {
channel_name: string,
channel_id: string,
signature: string,
signing_ts: string,
};
declare type PerChannelSettings = { declare type PerChannelSettings = {
words?: Array<string>, words?: Array<string>,
comments_enabled?: boolean, comments_enabled?: boolean,
@ -68,6 +75,10 @@ declare type CommentsState = {
settingsByChannelId: { [string]: PerChannelSettings }, // ChannelID -> settings settingsByChannelId: { [string]: PerChannelSettings }, // ChannelID -> settings
fetchingSettings: boolean, fetchingSettings: boolean,
fetchingBlockedWords: boolean, fetchingBlockedWords: boolean,
sblMine: any,
sblInvited: Array<any>,
fetchingSblMine: boolean,
fetchingSblInvited: boolean,
}; };
declare type CommentReactParams = { declare type CommentReactParams = {
@ -298,3 +309,53 @@ declare type BlockWordParams = {
signing_ts: string, signing_ts: string,
words: string, // CSV list of containing words to block comment on content 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_update: (params: UpdateSettingsParams) => fetchCommentsApi('setting.Update', params),
setting_get: (params: SettingsParams) => fetchCommentsApi('setting.Get', params), setting_get: (params: SettingsParams) => fetchCommentsApi('setting.Get', params),
super_list: (params: SuperListParams) => fetchCommentsApi('comment.SuperChatList', 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: {}) { 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> </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]: buildIcon(<polygon points="5 3 19 12 5 21 5 3" />),
[ICONS.PLAY_PREVIOUS]: buildIcon( [ICONS.PLAY_PREVIOUS]: buildIcon(
<g> <g>

View file

@ -37,6 +37,8 @@ const SwapPage = lazyImport(() => import('page/swap' /* webpackChunkName: "secon
const WalletPage = lazyImport(() => import('page/wallet' /* webpackChunkName: "secondary" */)); const WalletPage = lazyImport(() => import('page/wallet' /* webpackChunkName: "secondary" */));
// Chunk: none // 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 NotificationsPage = lazyImport(() => import('page/notifications' /* webpackChunkName: "secondary" */));
const CollectionPage = lazyImport(() => import('page/collection' /* webpackChunkName: "secondary" */)); const CollectionPage = lazyImport(() => import('page/collection' /* webpackChunkName: "secondary" */));
const ChannelNew = lazyImport(() => import('page/channelNew' /* webpackChunkName: "secondary" */)); const ChannelNew = lazyImport(() => import('page/channelNew' /* webpackChunkName: "secondary" */));
@ -77,6 +79,10 @@ const SettingsNotificationsPage = lazyImport(() =>
import('page/settingsNotifications' /* webpackChunkName: "secondary" */) import('page/settingsNotifications' /* webpackChunkName: "secondary" */)
); );
const SettingsPage = lazyImport(() => import('page/settings' /* 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 ShowPage = lazyImport(() => import('page/show' /* webpackChunkName: "secondary" */));
const TagsFollowingManagePage = lazyImport(() => const TagsFollowingManagePage = lazyImport(() =>
import('page/tagsFollowingManage' /* webpackChunkName: "secondary" */) import('page/tagsFollowingManage' /* webpackChunkName: "secondary" */)
@ -318,6 +324,10 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.LISTS}`} component={ListsPage} /> <PrivateRoute {...props} path={`/$/${PAGES.LISTS}`} component={ListsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} /> <PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} /> <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.SETTINGS_CREATOR}`} component={SettingsCreatorPage} />
<PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} /> <PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} /> <PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} />

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_STARTED = 'COMMENT_SUPER_CHAT_LIST_STARTED';
export const COMMENT_SUPER_CHAT_LIST_COMPLETED = 'COMMENT_SUPER_CHAT_LIST_COMPLETED'; 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_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 // Blocked channels
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL'; 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_TOP_LEVEL = 10;
export const COMMENT_PAGE_SIZE_REPLIES = 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 MUSIC = 'MusicCategory';
export const BADGE_MOD = 'BadgeMod'; export const BADGE_MOD = 'BadgeMod';
export const BADGE_STREAMER = 'BadgeStreamer'; export const BADGE_STREAMER = 'BadgeStreamer';
export const APPEAL = 'JudgeHammer';
export const REPLAY = 'Replay'; export const REPLAY = 'Replay';
export const REPEAT = 'Repeat'; export const REPEAT = 'Repeat';
export const SHUFFLE = 'Shuffle'; export const SHUFFLE = 'Shuffle';

View file

@ -74,5 +74,11 @@ exports.NOTIFICATIONS = 'notifications';
exports.YOUTUBE_SYNC = 'youtube'; exports.YOUTUBE_SYNC = 'youtube';
exports.LIVESTREAM = 'livestream'; exports.LIVESTREAM = 'livestream';
exports.LIVESTREAM_CURRENT = 'live'; 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.GENERAL = 'general';
exports.LIST = 'list'; exports.LIST = 'list';

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 = [
'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 moment from 'moment';
import humanizeDuration from 'humanize-duration'; import humanizeDuration from 'humanize-duration';
import BlockList from 'component/blockList'; import BlockList from 'component/blockList';
import BlockListShared from 'component/blockListShared';
import ClaimPreview from 'component/claimPreview'; import ClaimPreview from 'component/claimPreview';
import Page from 'component/page'; import Page from 'component/page';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
@ -17,6 +18,7 @@ import ChannelMuteButton from 'component/channelMuteButton';
const VIEW = { const VIEW = {
BLOCKED: 'blocked', BLOCKED: 'blocked',
ADMIN: 'admin', ADMIN: 'admin',
SHARED: 'shared',
MODERATOR: 'moderator', MODERATOR: 'moderator',
MUTED: 'muted', MUTED: 'muted',
}; };
@ -266,19 +268,24 @@ function ListBlocked(props: Props) {
{isAdmin && getViewElem(VIEW.ADMIN, 'Global', ICONS.BLOCK)} {isAdmin && getViewElem(VIEW.ADMIN, 'Global', ICONS.BLOCK)}
{isModerator && getViewElem(VIEW.MODERATOR, 'Moderator', ICONS.BLOCK)} {isModerator && getViewElem(VIEW.MODERATOR, 'Moderator', ICONS.BLOCK)}
{getViewElem(VIEW.MUTED, 'Muted', ICONS.MUTE)} {getViewElem(VIEW.MUTED, 'Muted', ICONS.MUTE)}
{getViewElem(VIEW.SHARED, 'Shared Blocklist', ICONS.SHARE)}
</div> </div>
<div className="section__actions--inline">{getRefreshElem()}</div> <div className="section__actions--inline">{getRefreshElem()}</div>
</div> </div>
<BlockList {viewMode === VIEW.SHARED && <BlockListShared />}
key={viewMode}
uris={getList(viewMode)} {viewMode !== VIEW.SHARED && (
help={getHelpText(viewMode)} <BlockList
titleEmptyList={getEmptyListTitle(viewMode)} key={viewMode}
subtitle={getEmptyListSubtitle(viewMode)} uris={getList(viewMode)}
getActionButtons={getActionButtons} help={getHelpText(viewMode)}
className={viewMode === VIEW.MODERATOR ? 'block-list--moderator' : undefined} titleEmptyList={getEmptyListTitle(viewMode)}
/> subtitle={getEmptyListSubtitle(viewMode)}
getActionButtons={getActionButtons}
className={viewMode === VIEW.MODERATOR ? 'block-list--moderator' : undefined}
/>
)}
</> </>
)} )}
</Page> </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: { BLOCKED_BY_CREATOR: {
commentron: 'channel is blocked by publisher', commentron: 'channel is blocked by publisher',
replacement: 'Unable to comment. This channel has blocked you.', replacement: 'Unable to comment. This channel has blocked you.',
linkText: 'Appeal',
linkTarget: `/${PAGES.APPEAL_LIST_OFFENCES}`,
}, },
BLOCKED_BY_ADMIN: { BLOCKED_BY_ADMIN: {
commentron: 'channel is not allowed to post comments', commentron: 'channel is not allowed to post comments',
@ -62,6 +64,12 @@ const ERR_MAP: CommentronErrorMap = {
commentron: 'duplicate comment!', commentron: 'duplicate comment!',
replacement: 'Please do not spam.', 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) { 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 settingsByChannelId: {}, // ChannelId -> PerChannelSettings
fetchingSettings: false, fetchingSettings: false,
fetchingBlockedWords: false, fetchingBlockedWords: false,
sblMine: undefined,
sblInvited: [],
fetchingSblMine: false,
fetchingSblInvited: false,
}; };
function pushToArrayInObject(obj, key, valueToPush) { function pushToArrayInObject(obj, key, valueToPush) {
@ -1072,6 +1076,45 @@ export default handleActions(
fetchingBlockedWords: false, 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 defaultState
); );

View file

@ -406,3 +406,8 @@ export const makeSelectSuperChatTotalAmountForUri = (uri: string) =>
return superChatData.totalAmount; 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; white-space: pre-line;
margin-right: var(--spacing-s); 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);
}

View file

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

View file

@ -338,6 +338,11 @@
max-width: 40rem; max-width: 40rem;
} }
.main--half-width {
@extend .main;
max-width: calc(var(--page-max-width) / 2);
}
.main--empty { .main--empty {
align-self: center; align-self: center;
display: flex; 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) { export function toCapitalCase(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1); 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');
}