From a34aa6969f56afd427f36ba5c5a8780991c49c7c Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Mon, 23 Aug 2021 17:52:08 +0800 Subject: [PATCH] SBL --- flow-typed/Comment.js | 61 ++++ ui/comments.js | 6 + ui/component/blockListShared/index.js | 33 ++ ui/component/blockListShared/view.jsx | 452 +++++++++++++++++++++++++ ui/component/common/icon-custom.jsx | 8 + ui/component/router/view.jsx | 10 + ui/constants/action_types.js | 7 + ui/constants/comment.js | 12 + ui/constants/icons.js | 1 + ui/constants/pages.js | 6 + ui/page/appealListOffences/index.js | 12 + ui/page/appealListOffences/view.jsx | 60 ++++ ui/page/appealReview/index.js | 12 + ui/page/appealReview/view.jsx | 60 ++++ ui/page/listBlocked/view.jsx | 25 +- ui/page/sharedBlocklistEdit/index.js | 17 + ui/page/sharedBlocklistEdit/view.jsx | 206 +++++++++++ ui/page/sharedBlocklistInvite/index.js | 17 + ui/page/sharedBlocklistInvite/view.jsx | 125 +++++++ ui/redux/actions/comments.js | 250 ++++++++++++++ ui/redux/reducers/comments.js | 43 +++ ui/redux/selectors/comments.js | 5 + ui/scss/component/_block-list.scss | 52 +++ ui/scss/component/_card.scss | 4 + ui/scss/component/_main.scss | 5 + ui/scss/component/_table.scss | 10 + ui/util/string.js | 8 + 27 files changed, 1498 insertions(+), 9 deletions(-) create mode 100644 ui/component/blockListShared/index.js create mode 100644 ui/component/blockListShared/view.jsx create mode 100644 ui/page/appealListOffences/index.js create mode 100644 ui/page/appealListOffences/view.jsx create mode 100644 ui/page/appealReview/index.js create mode 100644 ui/page/appealReview/view.jsx create mode 100644 ui/page/sharedBlocklistEdit/index.js create mode 100644 ui/page/sharedBlocklistEdit/view.jsx create mode 100644 ui/page/sharedBlocklistInvite/index.js create mode 100644 ui/page/sharedBlocklistInvite/view.jsx diff --git a/flow-typed/Comment.js b/flow-typed/Comment.js index 8b2ad570c..013d1f2ed 100644 --- a/flow-typed/Comment.js +++ b/flow-typed/Comment.js @@ -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, comments_enabled?: boolean, @@ -68,6 +75,10 @@ declare type CommentsState = { settingsByChannelId: { [string]: PerChannelSettings }, // ChannelID -> settings fetchingSettings: boolean, fetchingBlockedWords: boolean, + sblMine: any, + sblInvited: Array, + 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; diff --git a/ui/comments.js b/ui/comments.js index 315756401..63ace022e 100644 --- a/ui/comments.js +++ b/ui/comments.js @@ -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: {}) { diff --git a/ui/component/blockListShared/index.js b/ui/component/blockListShared/index.js new file mode 100644 index 000000000..1064a3736 --- /dev/null +++ b/ui/component/blockListShared/index.js @@ -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); diff --git a/ui/component/blockListShared/view.jsx b/ui/component/blockListShared/view.jsx new file mode 100644 index 000000000..258aed053 --- /dev/null +++ b/ui/component/blockListShared/view.jsx @@ -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: ( + <> +

{__('Are you sure you want to delete the shared blocklist?')}

+

{sblMine.shared_blocked_list.name.substring(0, 256)}

+ + ), + onConfirm: (closeModal) => { + if (activeChannelClaim) { + doSblDelete(activeChannelClaim); + } + closeModal(); + }, + }); + } + + function handleSblAccept(sblId: number, accepted: boolean) { + doOpenModal(MODALS.CONFIRM, { + title: accepted ? __('Accept invite') : __('Reject invite'), + body: accepted ? ( + <> +

{__('Participate in the selected shared blocklist?')}

+

{__('Any channels that you block in the future will be added to the list.')}

+

+ {__( + 'This will become the active shared blocklist, replacing your own (if exists) or any shared blocklist that you are currently participating.' + )} +

+ + ) : ( + <> +

{__('Stop participating from the selected shared blocklist?')}

+ + ), + 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 &&

{__('Remove the invitation for the following member?')}

} + {!expired &&

{__('Rescind the invitation for the following member?')}

} +

{memberClaim.name.substring(0, 256)}

+ + ), + 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 ( + , + }} + > + Shared blocklists allow a group of creators to all mutually manage a set of blocks that applies to all of their + channels. %learn_more% + + ); + } + + function getRowElem(label: string, value: any, truncate: boolean = false) { + return ( + + {label} + {truncate && ( + + + + )} + {!truncate && {value}} + + ); + } + + function getSwearJarAmountElem(amount: number) { + return (amount && ) || ; + } + + function getSblStatusElem(active) { + if (active) { + return ( +
+ {__('Active')} +
+ ); + } else { + return ( +
+ {__('Inactive')} +
+ ); + } + } + + function getSblInfoElem(sbl) { + const expanded = expandInvite[sbl.id] || sbl.id === mySblId; + return ( +
+ {expanded && ( + + + {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))} + +
+ )} + {sbl.id !== mySblId && ( +
+ ); + } + + function getMySblElem() { + if (sblMine) { + const sbl = sblMine.shared_blocked_list; + return ( + + {__('Your Shared Blocklist')} + {isUpdating && } + + } + actions={ + <> +
+
+
+ {getSblStatusElem(isMySblActive)} +
+ +
{getSblInfoElem(sbl)}
+ +
+
+ +
{getMembersElem()}
+ + } + /> + ); + } else { + return ( + } + /> + ); + } + } + + 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 ( + { + const status = getInviteStatus(members, claim); + return status === 'expired' ? ( +
{__('Expired')}
+ ) : status === 'accepted' ? ( +
{__('Accepted')}
+ ) : null; + }} + renderActions={(claim) => { + const expired = getInviteStatus(members, claim) === 'expired'; + return ( +
+
+ ); + }} + /> + ); + } else { + return ; + } + } + + function getInvitedSblElem() { + if (sblInvited && sblInvited.length > 0) { + return ( + + {sblInvited.map((i) => { + return ( +
+
+
+
+ {getSblStatusElem(i.invitation.status === 'accepted')} +
+ +
+ + + {getRowElem(__('From'), i.invitation.invited_by_channel_name)} + {getRowElem(__('Message'), i.invitation.message, !expandInvite[i.shared_blocked_list.id])} + +
+ {getSblInfoElem(i.shared_blocked_list)} +
+
+ ); + })} + + } + /> + ); + } + } + + // ************************************************************************** + // ************************************************************************** + + React.useEffect(() => { + fetchSblInfo(); + }, [activeChannelClaim, doSblGet, doSblListInvites]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+
{getHelpElem()}
+
+ +
+
{getMySblElem()}
+
{getInvitedSblElem()}
+
+ ); +} diff --git a/ui/component/common/icon-custom.jsx b/ui/component/common/icon-custom.jsx index dddd89857..2dbcb4424 100644 --- a/ui/component/common/icon-custom.jsx +++ b/ui/component/common/icon-custom.jsx @@ -2439,6 +2439,14 @@ export const icons = { /> ), + [ICONS.APPEAL]: buildIcon( + + + + + + + ), [ICONS.PLAY]: buildIcon(), [ICONS.PLAY_PREVIOUS]: buildIcon( diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx index 03db2b538..00775537d 100644 --- a/ui/component/router/view.jsx +++ b/ui/component/router/view.jsx @@ -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) { + + + + diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 7345e773f..262d33420 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -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'; diff --git a/ui/constants/comment.js b/ui/constants/comment.js index c265d3956..e40bfd628 100644 --- a/ui/constants/comment.js +++ b/ui/constants/comment.js @@ -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, +}; diff --git a/ui/constants/icons.js b/ui/constants/icons.js index f6fdc80b9..bc29aca91 100644 --- a/ui/constants/icons.js +++ b/ui/constants/icons.js @@ -171,6 +171,7 @@ export const STAR = 'star'; export const MUSIC = 'MusicCategory'; export const BADGE_MOD = 'BadgeMod'; export const BADGE_STREAMER = 'BadgeStreamer'; +export const APPEAL = 'JudgeHammer'; export const REPLAY = 'Replay'; export const REPEAT = 'Repeat'; export const SHUFFLE = 'Shuffle'; diff --git a/ui/constants/pages.js b/ui/constants/pages.js index db1853800..bca01e9ae 100644 --- a/ui/constants/pages.js +++ b/ui/constants/pages.js @@ -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'; diff --git a/ui/page/appealListOffences/index.js b/ui/page/appealListOffences/index.js new file mode 100644 index 000000000..e822e28d2 --- /dev/null +++ b/ui/page/appealListOffences/index.js @@ -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); diff --git a/ui/page/appealListOffences/view.jsx b/ui/page/appealListOffences/view.jsx new file mode 100644 index 000000000..1f9c146cd --- /dev/null +++ b/ui/page/appealListOffences/view.jsx @@ -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 ( + +
{HELP.OFFENCES}
+
+ null} + renderActions={(claim) => { + return ( +
+
+ ); + }} + /> +
+
+ ); +} + +// 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.', +}; diff --git a/ui/page/appealReview/index.js b/ui/page/appealReview/index.js new file mode 100644 index 000000000..c1a32d0c0 --- /dev/null +++ b/ui/page/appealReview/index.js @@ -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); diff --git a/ui/page/appealReview/view.jsx b/ui/page/appealReview/view.jsx new file mode 100644 index 000000000..29f665ed2 --- /dev/null +++ b/ui/page/appealReview/view.jsx @@ -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 ( + +
{HELP.APPELLANTS}
+
+ null} + renderActions={(claim) => { + return ( +
+
+ ); + }} + /> +
+
+ ); +} + +// prettier-ignore +const HELP = { + APPELLANTS: 'The following users have requested an appeal against blocking their channel.', +}; diff --git a/ui/page/listBlocked/view.jsx b/ui/page/listBlocked/view.jsx index 8048ee9f9..16c176ced 100644 --- a/ui/page/listBlocked/view.jsx +++ b/ui/page/listBlocked/view.jsx @@ -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)}
{getRefreshElem()}
- + {viewMode === VIEW.SHARED && } + + {viewMode !== VIEW.SHARED && ( + + )} )} diff --git a/ui/page/sharedBlocklistEdit/index.js b/ui/page/sharedBlocklistEdit/index.js new file mode 100644 index 000000000..c4e037fe6 --- /dev/null +++ b/ui/page/sharedBlocklistEdit/index.js @@ -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); diff --git a/ui/page/sharedBlocklistEdit/view.jsx b/ui/page/sharedBlocklistEdit/view.jsx new file mode 100644 index 000000000..9b220cc53 --- /dev/null +++ b/ui/page/sharedBlocklistEdit/view.jsx @@ -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 ( + +
+ + + setName(e.target.value)} + /> + setCategory(e.target.value)} + /> + setDescription(e.target.value)} + textAreaMaxLength={1000} + noEmojis + /> + + +
+ setStrike1(parseInt(e.target.value))} + /> + setStrike2(parseInt(e.target.value))} + /> + setStrike3(parseInt(e.target.value))} + /> +
+
+ }}>Auto-appeal minimum %lbc%} + name="curse_jar_amount" + className="form-field--price-amount" + type="number" + placeholder="1" + value={appealAmount} + onChange={(e) => setAppealAmount(parseFloat(e.target.value))} + /> + setInviteExpiration(parseInt(e.target.value))} + /> + setMemberInviteEnabled(!memberInviteEnabled)} + /> + + } + actions={ + <> +
+
+ {error &&
{__(error)}
} + + } + /> + +
+ ); +} diff --git a/ui/page/sharedBlocklistInvite/index.js b/ui/page/sharedBlocklistInvite/index.js new file mode 100644 index 000000000..f5622c8f2 --- /dev/null +++ b/ui/page/sharedBlocklistInvite/index.js @@ -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); diff --git a/ui/page/sharedBlocklistInvite/view.jsx b/ui/page/sharedBlocklistInvite/view.jsx new file mode 100644 index 000000000..35120970e --- /dev/null +++ b/ui/page/sharedBlocklistInvite/view.jsx @@ -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, 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 ( + +
+ + + + setMessage(e.target.value)} + textAreaMaxLength={1000} + noEmojis + /> + + } + actions={ + <> +
+
+ {error &&
{__(error)}
} + + } + /> + +
+ ); +} diff --git a/ui/redux/actions/comments.js b/ui/redux/actions/comments.js index 44947d109..d4b8e9f44 100644 --- a/ui/redux/actions/comments.js +++ b/ui/redux/actions/comments.js @@ -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, + 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 = []; + 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); + } + }); + }; +}; diff --git a/ui/redux/reducers/comments.js b/ui/redux/reducers/comments.js index d4fd5b926..04165ad32 100644 --- a/ui/redux/reducers/comments.js +++ b/ui/redux/reducers/comments.js @@ -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 ); diff --git a/ui/redux/selectors/comments.js b/ui/redux/selectors/comments.js index e19e236f4..80f22f68a 100644 --- a/ui/redux/selectors/comments.js +++ b/ui/redux/selectors/comments.js @@ -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); diff --git a/ui/scss/component/_block-list.scss b/ui/scss/component/_block-list.scss index b7178d820..49b6c378d 100644 --- a/ui/scss/component/_block-list.scss +++ b/ui/scss/component/_block-list.scss @@ -83,3 +83,55 @@ white-space: pre-line; margin-right: var(--spacing-s); } + +.sbl-edit { + .section__flex { + fieldset-section { + margin-top: 0; + margin-right: var(--spacing-m); + } + } +} + +.sbl { + .table { + width: 95%; + margin-left: var(--spacing-m); + } +} + +.sbl-info { + .button--link { + margin-top: var(--spacing-m); + width: 100%; + + .button__content { + align-items: center; + justify-content: center; + } + } +} + +.sbl_invite_expired { + align-self: flex-end; + color: var(--color-error); +} + +.sbl_invite_accepted { + align-self: flex-end; + color: #2bbb90; +} + +.sbl-status--active { + font-size: var(--font-small); + padding: 2px 8px; + border-radius: 5px; + font-style: italic; + color: white; + background: #2bbb90; +} + +.sbl-status--inactive { + @extend .sbl-status--active; + background: var(--color-gray-5); +} diff --git a/ui/scss/component/_card.scss b/ui/scss/component/_card.scss index fd9bbfb12..978b1a8a3 100644 --- a/ui/scss/component/_card.scss +++ b/ui/scss/component/_card.scss @@ -20,6 +20,10 @@ pointer-events: none; } +.card--disable-interaction { + pointer-events: none; +} + .card--section { position: relative; padding: var(--spacing-m); diff --git a/ui/scss/component/_main.scss b/ui/scss/component/_main.scss index 37c11b8a2..6fb649405 100644 --- a/ui/scss/component/_main.scss +++ b/ui/scss/component/_main.scss @@ -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; diff --git a/ui/scss/component/_table.scss b/ui/scss/component/_table.scss index db8ac028d..5d1241d9e 100644 --- a/ui/scss/component/_table.scss +++ b/ui/scss/component/_table.scss @@ -275,3 +275,13 @@ td { } } } + +.table--no-row-lines { + tr { + &:not(:last-of-type) { + td { + border-bottom: none; + } + } + } +} diff --git a/ui/util/string.js b/ui/util/string.js index e4cb060c5..31a13fd80 100644 --- a/ui/util/string.js +++ b/ui/util/string.js @@ -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'); +}