[New Feature] Stickers (#131)

* Refactor filePrice

* Refactor Wallet Tip Components

* Add backend sticker support for comments

* Add stickers

* Refactor commentCreate

* Add Sticker Selector and sticker comment creation

* Add stickers display to comments and hyperchats

* Fix wrong checks for total Super Chats
This commit is contained in:
saltrafael 2021-10-28 17:25:34 -03:00 committed by GitHub
parent a77e59cb53
commit 5f1f702490
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1428 additions and 1381 deletions

View file

@ -26,6 +26,8 @@ import CommentCreate from 'component/commentCreate';
import CommentMenuList from 'component/commentMenuList'; import CommentMenuList from 'component/commentMenuList';
import UriIndicator from 'component/uriIndicator'; import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import OptimizedImage from 'component/optimizedImage';
import { parseSticker } from 'util/comments';
const AUTO_EXPAND_ALL_REPLIES = false; const AUTO_EXPAND_ALL_REPLIES = false;
@ -138,6 +140,7 @@ function Comment(props: Props) {
const totalLikesAndDislikes = likesCount + dislikesCount; const totalLikesAndDislikes = likesCount + dislikesCount;
const slimedToDeath = totalLikesAndDislikes >= 5 && dislikesCount / totalLikesAndDislikes > 0.8; const slimedToDeath = totalLikesAndDislikes >= 5 && dislikesCount / totalLikesAndDislikes > 0.8;
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri; const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
const stickerFromMessage = parseSticker(message);
let channelOwnerOfContent; let channelOwnerOfContent;
try { try {
@ -338,6 +341,10 @@ function Comment(props: Props) {
<div onClick={() => setDisplayDeadComment(true)} className="comment__dead"> <div onClick={() => setDisplayDeadComment(true)} className="comment__dead">
{__('This comment was slimed to death.')} <Icon icon={ICONS.SLIME_ACTIVE} /> {__('This comment was slimed to death.')} <Icon icon={ICONS.SLIME_ACTIVE} />
</div> </div>
) : stickerFromMessage ? (
<div className="sticker__comment">
<OptimizedImage src={stickerFromMessage.url} waitLoad />
</div>
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? ( ) : editedMessage.length >= LENGTH_TO_COLLAPSE ? (
<Expandable> <Expandable>
<MarkdownPreview <MarkdownPreview

View file

@ -6,26 +6,26 @@ import {
selectFetchingMyChannels, selectFetchingMyChannels,
makeSelectTagInClaimOrChannelForUri, makeSelectTagInClaimOrChannelForUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { doSendTip } from 'redux/actions/wallet'; import { CommentCreate } from './view';
import { DISABLE_SUPPORT_TAG } from 'constants/tags';
import { doCommentCreate, doFetchCreatorSettings, doCommentById } from 'redux/actions/comments'; import { doCommentCreate, doFetchCreatorSettings, doCommentById } from 'redux/actions/comments';
import { doSendTip, doSendCashTip } from 'redux/actions/wallet';
import { doToast } from 'redux/actions/notifications';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { selectSettingsByChannelId } from 'redux/selectors/comments'; import { selectSettingsByChannelId } from 'redux/selectors/comments';
import { CommentCreate } from './view';
import { doToast } from 'redux/actions/notifications';
import { DISABLE_SUPPORT_TAG } from 'constants/tags';
const select = (state, props) => ({ const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
channels: selectMyChannelClaims(state),
isFetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state), activeChannelClaim: selectActiveChannelClaim(state),
channels: selectMyChannelClaims(state),
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state), claimIsMine: makeSelectClaimIsMine(props.uri)(state),
isFetchingChannels: selectFetchingMyChannels(state),
settingsByChannelId: selectSettingsByChannelId(state), settingsByChannelId: selectSettingsByChannelId(state),
supportDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_SUPPORT_TAG)(state), supportDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_SUPPORT_TAG)(state),
}); });
const perform = (dispatch, ownProps) => ({ const perform = (dispatch, ownProps) => ({
createComment: (comment, claimId, parentId, txid, payment_intent_id, environment) => createComment: (comment, claimId, parentId, txid, payment_intent_id, environment, sticker) =>
dispatch( dispatch(
doCommentCreate( doCommentCreate(
comment, comment,
@ -35,13 +35,16 @@ const perform = (dispatch, ownProps) => ({
ownProps.livestream, ownProps.livestream,
txid, txid,
payment_intent_id, payment_intent_id,
environment environment,
sticker
) )
), ),
sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)),
doToast: (options) => dispatch(doToast(options)),
doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)), doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)),
doToast: (options) => dispatch(doToast(options)),
fetchComment: (commentId) => dispatch(doCommentById(commentId, false)), fetchComment: (commentId) => dispatch(doCommentById(commentId, false)),
sendCashTip: (tipParams, userParams, claimId, environment, successCallback) =>
dispatch(doSendCashTip(tipParams, false, userParams, claimId, environment, successCallback)),
sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)),
}); });
export default connect(select, perform)(CommentCreate); export default connect(select, perform)(CommentCreate);

View file

@ -0,0 +1,94 @@
// @flow
import 'scss/component/_sticker-selector.scss';
import { FREE_GLOBAL_STICKERS, PAID_GLOBAL_STICKERS } from 'constants/stickers';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import CreditAmount from 'component/common/credit-amount';
import OptimizedImage from 'component/optimizedImage';
import React from 'react';
const buildStickerSideLink = (section: string, icon: string) => ({ section, icon });
const STICKER_SIDE_LINKS = [
buildStickerSideLink(__('Free'), ICONS.TAG),
buildStickerSideLink(__('Tips'), ICONS.FINANCE),
// Future work may include Channel, Subscriptions, ...
];
type Props = { claimIsMine: boolean, onSelect: (any) => void };
export default function StickerSelector(props: Props) {
const { claimIsMine, onSelect } = props;
function scrollToStickerSection(section: string) {
const listBodyEl = document.querySelector('.stickerSelector__listBody');
const sectionToScroll = document.getElementById(section);
if (listBodyEl && sectionToScroll) {
// $FlowFixMe
listBodyEl.scrollTo({
top: sectionToScroll.offsetTop - sectionToScroll.getBoundingClientRect().height * 2,
behavior: 'smooth',
});
}
}
const getListRow = (rowTitle: string, rowStickers: any) => (
<div className="stickerSelector__listBody-row">
<div id={rowTitle} className="stickerSelector__listBody-rowTitle">
{rowTitle}
</div>
<div className="stickerSelector__listBody-rowItems">
{rowStickers.map((sticker) => (
<Button
key={sticker.name}
title={sticker.name}
button="alt"
className="button--file-action"
onClick={() => onSelect(sticker)}
>
<OptimizedImage src={sticker.url} waitLoad />
{sticker.price && sticker.price > 0 && (
<CreditAmount superChatLight amount={sticker.price} size={2} isFiat />
)}
</Button>
))}
</div>
</div>
);
return (
<div className="stickerSelector">
<div className="stickerSelector__header card__header--between">
<div className="stickerSelector__headerTitle card__title-section--small">{__('Stickers')}</div>
</div>
<div className="stickerSelector__list">
<div className="stickerSelector__listBody">
{getListRow(__('Free'), FREE_GLOBAL_STICKERS)}
{!claimIsMine && getListRow(__('Tips'), PAID_GLOBAL_STICKERS)}
</div>
<div className="navigation__wrapper">
<ul className="navigation-links">
{STICKER_SIDE_LINKS.map(
(linkProps) =>
((claimIsMine && linkProps.section !== 'Tips') || !claimIsMine) && (
<li key={linkProps.section}>
<Button
label={__(linkProps.section)}
title={__(linkProps.section)}
icon={linkProps.icon}
iconSize={1}
className="navigation-link"
onClick={() => scrollToStickerSection(linkProps.section)}
/>
</li>
)
)}
</ul>
</div>
</div>
</div>
);
}

View file

@ -1,9 +1,9 @@
// @flow // @flow
import 'scss/component/_comment-create.scss'; import 'scss/component/_comment-create.scss';
import { buildValidSticker } from 'util/comments';
import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field'; import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
import { FormField, Form } from 'component/common/form'; import { FormField, Form } from 'component/common/form';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import { Lbryio } from 'lbryinc';
import { SIMPLE_SITE } from 'config'; import { SIMPLE_SITE } from 'config';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
@ -16,99 +16,113 @@ import classnames from 'classnames';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import EmoteSelector from './emote-selector'; import EmoteSelector from './emote-selector';
import Empty from 'component/common/empty'; import Empty from 'component/common/empty';
import FilePrice from 'component/filePrice';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import OptimizedImage from 'component/optimizedImage';
import React from 'react'; import React from 'react';
import SelectChannel from 'component/selectChannel'; import SelectChannel from 'component/selectChannel';
import StickerSelector from './sticker-selector';
import type { ElementRef } from 'react'; import type { ElementRef } from 'react';
import UriIndicator from 'component/uriIndicator'; import UriIndicator from 'component/uriIndicator';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import WalletTipAmountSelector from 'component/walletTipAmountSelector'; import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { getStripeEnvironment } from 'util/stripe'; import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment(); const stripeEnvironment = getStripeEnvironment();
const TAB_FIAT = 'TabFiat'; const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC'; const TAB_LBC = 'TabLBC';
const MENTION_DEBOUNCE_MS = 100; const MENTION_DEBOUNCE_MS = 100;
type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string };
type UserParams = { activeChannelName: ?string, activeChannelId: ?string };
type Props = { type Props = {
uri: string,
claim: StreamClaim,
channels: ?Array<ChannelClaim>,
isNested: boolean,
isFetchingChannels: boolean,
parentId: string,
isReply: boolean,
activeChannel: string, activeChannel: string,
activeChannelClaim: ?ChannelClaim, activeChannelClaim: ?ChannelClaim,
bottom: boolean, bottom: boolean,
livestream?: boolean, channels: ?Array<ChannelClaim>,
embed?: boolean, claim: StreamClaim,
claimIsMine: boolean, claimIsMine: boolean,
supportDisabled: boolean, embed?: boolean,
isFetchingChannels: boolean,
isNested: boolean,
isReply: boolean,
livestream?: boolean,
parentId: string,
settingsByChannelId: { [channelId: string]: PerChannelSettings }, settingsByChannelId: { [channelId: string]: PerChannelSettings },
shouldFetchComment: boolean, shouldFetchComment: boolean,
doToast: ({ message: string }) => void, supportDisabled: boolean,
createComment: (string, string, string, ?string, ?string, ?string) => Promise<any>, uri: string,
onDoneReplying?: () => void, createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
onCancelReplying?: () => void,
toast: (string) => void,
sendTip: ({}, (any) => void, (any) => void) => void,
doFetchCreatorSettings: (channelId: string) => Promise<any>, doFetchCreatorSettings: (channelId: string) => Promise<any>,
setQuickReply: (any) => void, doToast: ({ message: string }) => void,
fetchComment: (commentId: string) => Promise<any>, fetchComment: (commentId: string) => Promise<any>,
onCancelReplying?: () => void,
onDoneReplying?: () => void,
sendCashTip: (TipParams, UserParams, string, ?string, (any) => void) => string,
sendTip: ({}, (any) => void, (any) => void) => void,
setQuickReply: (any) => void,
toast: (string) => void,
}; };
export function CommentCreate(props: Props) { export function CommentCreate(props: Props) {
const { const {
uri,
claim,
channels,
isNested,
isFetchingChannels,
isReply,
parentId,
activeChannelClaim, activeChannelClaim,
bottom, bottom,
livestream, channels,
embed, claim,
claimIsMine, claimIsMine,
embed,
isFetchingChannels,
isNested,
isReply,
livestream,
parentId,
settingsByChannelId, settingsByChannelId,
supportDisabled,
shouldFetchComment, shouldFetchComment,
doToast, supportDisabled,
uri,
createComment, createComment,
onDoneReplying,
onCancelReplying,
sendTip,
doFetchCreatorSettings, doFetchCreatorSettings,
setQuickReply, doToast,
fetchComment, fetchComment,
onCancelReplying,
onDoneReplying,
sendCashTip,
sendTip,
setQuickReply,
} = props; } = props;
const formFieldRef: ElementRef<any> = React.useRef(); const formFieldRef: ElementRef<any> = React.useRef();
const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input; const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input;
const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart; const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart;
const buttonRef: ElementRef<any> = React.useRef(); const buttonRef: ElementRef<any> = React.useRef();
const { const {
push, push,
location: { pathname }, location: { pathname },
} = useHistory(); } = useHistory();
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setSubmitting] = React.useState(false);
const [commentFailure, setCommentFailure] = React.useState(false); const [commentFailure, setCommentFailure] = React.useState(false);
const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined }); const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined });
const [isSupportComment, setIsSupportComment] = React.useState(); const [isSupportComment, setIsSupportComment] = React.useState();
const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState(); const [isReviewingSupportComment, setReviewingSupportComment] = React.useState();
const [isReviewingStickerComment, setReviewingStickerComment] = React.useState();
const [selectedSticker, setSelectedSticker] = React.useState();
const [tipAmount, setTipAmount] = React.useState(1); const [tipAmount, setTipAmount] = React.useState(1);
const [convertedAmount, setConvertedAmount] = React.useState();
const [commentValue, setCommentValue] = React.useState(''); const [commentValue, setCommentValue] = React.useState('');
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const [stickerSelector, setStickerSelector] = React.useState();
const [activeTab, setActiveTab] = React.useState(''); const [activeTab, setActiveTab] = React.useState('');
const [tipError, setTipError] = React.useState(); const [tipError, setTipError] = React.useState();
const [deletedComment, setDeletedComment] = React.useState(false); const [deletedComment, setDeletedComment] = React.useState(false);
const [pauseQuickSend, setPauseQuickSend] = React.useState(false); const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
const [showEmotes, setShowEmotes] = React.useState(false); const [showEmotes, setShowEmotes] = React.useState(false);
const [disableReviewButton, setDisableReviewButton] = React.useState();
const selectedMentionIndex = const selectedMentionIndex =
commentValue.indexOf('@', selectionIndex) === selectionIndex commentValue.indexOf('@', selectionIndex) === selectionIndex
@ -128,8 +142,7 @@ export function CommentCreate(props: Props) {
: ''; : '';
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const signingChannel = (claim && claim.signing_channel) || claim; const channelUri = claim && (claim.signing_channel ? claim.signing_channel.permanent_url : claim.permanent_url);
const channelUri = signingChannel && signingChannel.permanent_url;
const hasChannels = channels && channels.length; const hasChannels = channels && channels.length;
const charCount = commentValue ? commentValue.length : 0; const charCount = commentValue ? commentValue.length : 0;
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend; const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend;
@ -143,31 +156,23 @@ export function CommentCreate(props: Props) {
const minAmountRef = React.useRef(minAmount); const minAmountRef = React.useRef(minAmount);
minAmountRef.current = minAmount; minAmountRef.current = minAmount;
const MinAmountNotice = minAmount ? (
<div className="help--notice comment--min-amount-notice">
<I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}>
{minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
</I18nMessage>
<Icon
customTooltipText={
minTip
? __('This channel requires a minimum tip for each comment.')
: minSuper
? __('This channel requires a minimum amount for HyperChats to be visible.')
: ''
}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</div>
) : null;
// ************************************************************************** // **************************************************************************
// Functions // Functions
// ************************************************************************** // **************************************************************************
function handleSelectSticker(sticker: any) {
// $FlowFixMe
setSelectedSticker(sticker);
setReviewingStickerComment(true);
setTipAmount(sticker.price || 0);
setStickerSelector(false);
if (sticker.price && sticker.price > 0) {
setActiveTab(TAB_FIAT);
setIsSupportComment(true);
}
}
function handleCommentChange(event) { function handleCommentChange(event) {
let commentValue; let commentValue;
if (isReply) { if (isReply) {
@ -200,18 +205,8 @@ export function CommentCreate(props: Props) {
} }
} }
function onTextareaFocus() {
window.addEventListener('keydown', altEnterListener);
}
function onTextareaBlur() {
window.removeEventListener('keydown', altEnterListener);
}
function handleSupportComment() { function handleSupportComment() {
if (!activeChannelClaim) { if (!activeChannelClaim) return;
return;
}
if (!channelId) { if (!channelId) {
doToast({ doToast({
@ -239,7 +234,7 @@ export function CommentCreate(props: Props) {
message: __('The creator just updated the minimum setting. Please revise or double-check your tip amount.'), message: __('The creator just updated the minimum setting. Please revise or double-check your tip amount.'),
isError: true, isError: true,
}); });
setIsReviewingSupportComment(false); setReviewingSupportComment(false);
return; return;
} }
@ -248,33 +243,17 @@ export function CommentCreate(props: Props) {
} }
function doSubmitTip() { function doSubmitTip() {
if (!activeChannelClaim) { if (!activeChannelClaim) return;
return;
}
const params = {
amount: tipAmount,
claim_id: claimId,
channel_id: activeChannelClaim.claim_id,
};
const params = { amount: tipAmount, claim_id: claimId, channel_id: activeChannelClaim.claim_id };
const activeChannelName = activeChannelClaim && activeChannelClaim.name; const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id; const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
// setup variables for tip API // setup variables for tip API
let channelClaimId, tipChannelName; const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id;
// if there is a signing channel it's on a file const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name;
if (claim.signing_channel) {
channelClaimId = claim.signing_channel.claim_id;
tipChannelName = claim.signing_channel.name;
// otherwise it's on the channel page setSubmitting(true);
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
}
setIsSubmitting(true);
if (activeTab === TAB_LBC) { if (activeTab === TAB_LBC) {
// call sendTip and then run the callback from the response // call sendTip and then run the callback from the response
@ -302,59 +281,24 @@ export function CommentCreate(props: Props) {
}, },
() => { () => {
// reset the frontend so people can send a new comment // reset the frontend so people can send a new comment
setIsSubmitting(false); setSubmitting(false);
} }
); );
} else { } else {
const sourceClaimId = claim.claim_id; const tipParams: TipParams = { tipAmount: Math.round(tipAmount * 100) / 100, tipChannelName, channelClaimId };
const roundedAmount = Math.round(tipAmount * 100) / 100; const userParams: UserParams = { activeChannelName, activeChannelId };
Lbryio.call( sendCashTip(tipParams, userParams, claim.claim_id, stripeEnvironment, (customerTipResponse) => {
'customer', const { payment_intent_id } = customerTipResponse;
'tip',
{
// round to deal with floating point precision
amount: Math.round(100 * roundedAmount), // convert from dollars to cents
creator_channel_name: tipChannelName, // creator_channel_name
creator_channel_claim_id: channelClaimId,
tipper_channel_name: activeChannelName,
tipper_channel_claim_id: activeChannelId,
currency: 'USD',
anonymous: false,
source_claim_id: sourceClaimId,
environment: stripeEnvironment,
},
'post'
)
.then((customerTipResponse) => {
const paymentIntendId = customerTipResponse.payment_intent_id;
handleCreateComment(null, paymentIntendId, stripeEnvironment); handleCreateComment(null, payment_intent_id, stripeEnvironment);
setCommentValue(''); setCommentValue('');
setIsReviewingSupportComment(false); setReviewingSupportComment(false);
setIsSupportComment(false); setIsSupportComment(false);
setCommentFailure(false); setCommentFailure(false);
setIsSubmitting(false); setSubmitting(false);
});
doToast({
message: __("You sent $%formattedAmount% as a tip to %tipChannelName%, I'm sure they appreciate it!", {
formattedAmount: roundedAmount.toFixed(2), // force show decimal places
tipChannelName,
}),
});
// handleCreateComment(null);
})
.catch((error) => {
doToast({
message:
error.message !== 'payment intent failed to confirm'
? error.message
: 'Sorry, there was an error in processing your payment!',
isError: true,
});
});
} }
} }
@ -366,16 +310,17 @@ export function CommentCreate(props: Props) {
*/ */
function handleCreateComment(txid, payment_intent_id, environment) { function handleCreateComment(txid, payment_intent_id, environment) {
setShowEmotes(false); setShowEmotes(false);
setIsSubmitting(true); setSubmitting(true);
const stickerValue = selectedSticker && buildValidSticker(selectedSticker.name);
createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment) createComment(stickerValue || commentValue, claimId, parentId, txid, payment_intent_id, environment, !!stickerValue)
.then((res) => { .then((res) => {
setIsSubmitting(false); setSubmitting(false);
if (setQuickReply) setQuickReply(res); if (setQuickReply) setQuickReply(res);
if (res && res.signature) { if (res && res.signature) {
setCommentValue(''); setCommentValue('');
setIsReviewingSupportComment(false); setReviewingSupportComment(false);
setIsSupportComment(false); setIsSupportComment(false);
setCommentFailure(false); setCommentFailure(false);
@ -385,7 +330,7 @@ export function CommentCreate(props: Props) {
} }
}) })
.catch(() => { .catch(() => {
setIsSubmitting(false); setSubmitting(false);
setCommentFailure(true); setCommentFailure(true);
if (channelId) { if (channelId) {
@ -432,6 +377,10 @@ export function CommentCreate(props: Props) {
// Render // Render
// ************************************************************************** // **************************************************************************
const getActionButton = (title: string, icon: string, handleClick: () => void) => (
<Button title={title} button="alt" icon={icon} onClick={handleClick} />
);
if (channelSettings && !channelSettings.comments_enabled) { if (channelSettings && !channelSettings.comments_enabled) {
return <Empty padded text={__('This channel has disabled comments on their page.')} />; return <Empty padded text={__('This channel has disabled comments on their page.')} />;
} }
@ -456,30 +405,123 @@ export function CommentCreate(props: Props) {
> >
<FormField type="textarea" name={'comment_signup_prompt'} placeholder={__('Say something about this...')} /> <FormField type="textarea" name={'comment_signup_prompt'} placeholder={__('Say something about this...')} />
<div className="section__actions--no-margin"> <div className="section__actions--no-margin">
<Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth={IS_WEB} /> <Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth />
</div> </div>
</div> </div>
); );
} }
if (isReviewingSupportComment && activeChannelClaim) { return (
return ( <Form
<div className="comment__create"> className={classnames('commentCreate', {
<div className="comment__sc-preview"> 'commentCreate--reply': isReply,
'commentCreate--nestedReply': isNested,
'commentCreate--bottom': bottom,
})}
>
{/* Input Box/Preview Box */}
{stickerSelector ? (
<StickerSelector onSelect={(sticker) => handleSelectSticker(sticker)} claimIsMine={claimIsMine} />
) : isReviewingStickerComment && activeChannelClaim && selectedSticker ? (
<div className="commentCreate__stickerPreview">
<div className="commentCreate__stickerPreviewInfo">
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<UriIndicator uri={activeChannelClaim.canonical_url} link />
</div>
<div className="commentCreate__stickerPreviewImage">
<OptimizedImage src={selectedSticker && selectedSticker.url} waitLoad />
</div>
{selectedSticker.price && <FilePrice customPrice={selectedSticker.price} isFiat />}
</div>
) : isReviewingSupportComment && activeChannelClaim ? (
<div className="commentCreate__supportCommentPreview">
<CreditAmount <CreditAmount
className="comment__sc-preview-amount"
isFiat={activeTab === TAB_FIAT}
amount={tipAmount} amount={tipAmount}
className="commentCreate__supportCommentPreviewAmount"
isFiat={activeTab === TAB_FIAT}
size={activeTab === TAB_LBC ? 18 : 2} size={activeTab === TAB_LBC ? 18 : 2}
/> />
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} /> <ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<div> <div className="commentCreate__supportCommentBody">
<UriIndicator uri={activeChannelClaim.name} link /> <UriIndicator uri={activeChannelClaim.canonical_url} link />
<div>{commentValue}</div> <div>{commentValue}</div>
</div> </div>
</div> </div>
<div className="section__actions--no-margin"> ) : (
<>
{showEmotes && (
<EmoteSelector
commentValue={commentValue}
setCommentValue={setCommentValue}
closeSelector={() => setShowEmotes(false)}
/>
)}
{!advancedEditor && (
<ChannelMentionSuggestions
uri={uri}
isLivestream={livestream}
inputRef={formFieldInputRef}
mentionTerm={channelMention}
creatorUri={channelUri}
customSelectAction={handleSelectMention}
/>
)}
<FormField
disabled={isFetchingChannels}
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
name={isReply ? 'content_reply' : 'content_description'}
ref={formFieldRef}
className={isReply ? 'content_reply' : 'content_comment'}
label={
<span className="commentCreate__labelWrapper">
{!livestream && (
<div className="commentCreate__label">{isReply ? __('Replying as ') : __('Comment as ')}</div>
)}
<SelectChannel tiny />
</span>
}
quickActionLabel={
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
}
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
openEmoteMenu={() => setShowEmotes(!showEmotes)}
onFocus={() => window.addEventListener('keydown', altEnterListener)}
onBlur={() => window.removeEventListener('keydown', altEnterListener)}
placeholder={__('Say something about this...')}
value={commentValue}
charCount={charCount}
onChange={handleCommentChange}
autoFocus={isReply}
textAreaMaxLength={livestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
/>
</>
)}
{(isSupportComment || (isReviewingStickerComment && selectedSticker && selectedSticker.price)) && (
<WalletTipAmountSelector
activeTab={activeTab}
amount={tipAmount}
claim={claim}
convertedAmount={convertedAmount}
customTipAmount={selectedSticker && selectedSticker.price}
fiatConversion={selectedSticker && !!selectedSticker.price}
onChange={(amount) => setTipAmount(amount)}
setConvertedAmount={setConvertedAmount}
setDisableSubmitButton={setDisableReviewButton}
setTipError={setTipError}
tipError={tipError}
/>
)}
{/* Bottom Action Buttons */}
<div className="section__actions section__actions--no-margin">
{/* Submit Button */}
{isReviewingSupportComment ? (
<Button <Button
autoFocus autoFocus
button="primary" button="primary"
@ -493,166 +535,146 @@ export function CommentCreate(props: Props) {
} }
onClick={handleSupportComment} onClick={handleSupportComment}
/> />
) : isReviewingStickerComment && selectedSticker ? (
<Button <Button
disabled={isSubmitting} button="primary"
button="link" label={__('Send')}
label={__('Cancel')} disabled={
onClick={() => setIsReviewingSupportComment(false)} (isSupportComment && (tipError || disableReviewButton)) ||
(selectedSticker &&
selectedSticker.price &&
(activeTab === TAB_FIAT
? tipAmount < selectedSticker.price
: convertedAmount && convertedAmount < selectedSticker.price))
}
onClick={() => {
if (isSupportComment) {
handleSupportComment();
} else {
handleCreateComment();
}
setSelectedSticker(null);
setReviewingStickerComment(false);
setStickerSelector(false);
setIsSupportComment(false);
}}
/>
) : isSupportComment ? (
<Button
disabled={disabled || tipError || disableReviewButton || !minAmountMet}
type="button"
button="primary"
icon={activeTab === TAB_LBC ? ICONS.LBC : ICONS.FINANCE}
label={__('Review')}
onClick={() => setReviewingSupportComment(true)}
requiresAuth
/> />
{MinAmountNotice}
</div>
</div>
);
}
return (
<Form
className={classnames('comment__create', {
'comment__create--reply': isReply,
'comment__create--nested-reply': isNested,
'comment__create--bottom': bottom,
})}
>
{showEmotes && (
<EmoteSelector
commentValue={commentValue}
setCommentValue={setCommentValue}
closeSelector={() => setShowEmotes(false)}
/>
)}
{!advancedEditor && (
<ChannelMentionSuggestions
uri={uri}
isLivestream={livestream}
inputRef={formFieldInputRef}
mentionTerm={channelMention}
creatorUri={channelUri}
customSelectAction={handleSelectMention}
/>
)}
<FormField
disabled={isFetchingChannels}
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
name={isReply ? 'content_reply' : 'content_description'}
ref={formFieldRef}
className={isReply ? 'content_reply' : 'content_comment'}
label={
<span className="comment-new__label-wrapper">
{!livestream && (
<div className="comment-new__label">{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}</div>
)}
<SelectChannel tiny />
</span>
}
quickActionLabel={
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
}
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
openEmoteMenu={() => setShowEmotes(!showEmotes)}
onFocus={onTextareaFocus}
onBlur={onTextareaBlur}
placeholder={__('Say something about this...')}
value={commentValue}
charCount={charCount}
onChange={handleCommentChange}
autoFocus={isReply}
textAreaMaxLength={livestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
/>
{isSupportComment && (
<WalletTipAmountSelector
onTipErrorChange={setTipError}
shouldDisableReviewButton={setShouldDisableReviewButton}
claim={claim}
activeTab={activeTab}
amount={tipAmount}
onChange={(amount) => setTipAmount(amount)}
/>
)}
<div className="section__actions section__actions--no-margin">
{isSupportComment ? (
<>
<Button
disabled={disabled || tipError || shouldDisableReviewButton || !minAmountMet}
type="button"
button="primary"
icon={activeTab === TAB_LBC ? ICONS.LBC : ICONS.FINANCE}
label={__('Review')}
onClick={() => setIsReviewingSupportComment(true)}
requiresAuth={IS_WEB}
/>
<Button
disabled={isSubmitting}
button="link"
label={__('Cancel')}
onClick={() => setIsSupportComment(false)}
/>
</>
) : ( ) : (
(!minTip || claimIsMine) && (
<Button
ref={buttonRef}
button="primary"
disabled={disabled || stickerSelector}
type="submit"
label={
isReply
? isSubmitting
? __('Replying...')
: __('Reply')
: isSubmitting
? __('Commenting...')
: __('Comment --[button to submit something]--')
}
requiresAuth
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
/>
)
)}
{/** Stickers/Support Buttons **/}
{!supportDisabled && !stickerSelector && (
<> <>
{(!minTip || claimIsMine) && ( {isReviewingStickerComment ? (
<Button <Button
ref={buttonRef} button="alt"
button="primary" label={__('Different Sticker')}
disabled={disabled}
type="submit"
label={
isReply
? isSubmitting
? __('Replying...')
: __('Reply')
: isSubmitting
? __('Commenting...')
: __('Comment --[button to submit something]--')
}
requiresAuth={IS_WEB}
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
/>
)}
{!supportDisabled && !claimIsMine && (
<>
<Button
disabled={disabled}
button="alt"
className="thatButton"
icon={ICONS.LBC}
onClick={() => {
setIsSupportComment(true);
setActiveTab(TAB_LBC);
}}
/>
{/* @if TARGET='web' */}
{stripeEnvironment && (
<Button
disabled={disabled}
button="alt"
className="thisButton"
icon={ICONS.FINANCE}
onClick={() => {
setIsSupportComment(true);
setActiveTab(TAB_FIAT);
}}
/>
)}
{/* @endif */}
</>
)}
{isReply && !minTip && (
<Button
button="link"
label={__('Cancel')}
onClick={() => { onClick={() => {
if (onCancelReplying) { setReviewingStickerComment(false);
onCancelReplying(); setIsSupportComment(false);
} setStickerSelector(true);
}} }}
/> />
) : (
getActionButton(__('Stickers'), ICONS.TAG, () => {
setIsSupportComment(false);
setStickerSelector(true);
})
)} )}
{!claimIsMine &&
getActionButton(__('LBC'), ICONS.LBC, () => {
setIsSupportComment(true);
setActiveTab(TAB_LBC);
})}
{!claimIsMine &&
stripeEnvironment &&
getActionButton(__('Cash'), ICONS.FINANCE, () => {
setIsSupportComment(true);
setActiveTab(TAB_FIAT);
})}
</> </>
)} )}
{/* Cancel Button */}
{(isSupportComment ||
isReviewingSupportComment ||
stickerSelector ||
isReviewingStickerComment ||
(isReply && !minTip)) && (
<Button
disabled={isSupportComment && isSubmitting}
button="link"
label={__('Cancel')}
onClick={() => {
if (isSupportComment || isReviewingSupportComment) {
if (!isReviewingSupportComment) setIsSupportComment(false);
setReviewingSupportComment(false);
if (selectedSticker && selectedSticker.price) {
setReviewingStickerComment(false);
setStickerSelector(false);
setSelectedSticker(null);
}
} else if (stickerSelector || isReviewingStickerComment) {
setReviewingStickerComment(false);
setStickerSelector(false);
} else if (isReply && !minTip && onCancelReplying) {
onCancelReplying();
}
}}
/>
)}
{/* Help Text */}
{deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>} {deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>}
{MinAmountNotice} {!!minAmount && (
<div className="help--notice commentCreate__minAmountNotice">
<I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}>
{minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
</I18nMessage>
<Icon
customTooltipText={
minTip
? __('This channel requires a minimum tip for each comment.')
: minSuper
? __('This channel requires a minimum amount for HyperChats to be visible.')
: ''
}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</div>
)}
</div> </div>
</Form> </Form>
); );

View file

@ -4,15 +4,11 @@ import { makeSelectCostInfoForUri, doFetchCostInfoForUri, makeSelectFetchingCost
import FilePrice from './view'; import FilePrice from './view';
const select = (state, props) => ({ const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
claimWasPurchased: makeSelectClaimWasPurchased(props.uri)(state),
costInfo: makeSelectCostInfoForUri(props.uri)(state), costInfo: makeSelectCostInfoForUri(props.uri)(state),
fetching: makeSelectFetchingCostInfoForUri(props.uri)(state), fetching: makeSelectFetchingCostInfoForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
claimWasPurchased: makeSelectClaimWasPurchased(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
}); });
const perform = (dispatch) => ({ export default connect(select, { doFetchCostInfoForUri })(FilePrice);
fetchCostInfo: (uri) => dispatch(doFetchCostInfoForUri(uri)),
});
export default connect(select, perform)(FilePrice);

View file

@ -1,30 +1,30 @@
// @flow // @flow
import 'scss/component/_file-price.scss';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import React from 'react';
type Props = { type Props = {
showFullPrice: boolean,
costInfo: ?{ includesData: boolean, cost: number },
fetchCostInfo: string => void,
uri: string,
fetching: boolean,
claim: ?{}, claim: ?{},
claimWasPurchased: boolean,
claimIsMine: boolean, claimIsMine: boolean,
claimWasPurchased: boolean,
costInfo?: ?{ includesData: boolean, cost: number },
fetching: boolean,
showFullPrice: boolean,
type?: string, type?: string,
uri: string,
// below props are just passed to <CreditAmount /> // below props are just passed to <CreditAmount />
inheritStyle?: boolean, customPrice: number,
showLBC?: boolean,
hideFree?: boolean, // hide the file price if it's free hideFree?: boolean, // hide the file price if it's free
isFiat?: boolean,
showLBC?: boolean,
doFetchCostInfoForUri: (string) => void,
}; };
class FilePrice extends React.PureComponent<Props> { class FilePrice extends React.PureComponent<Props> {
static defaultProps = { static defaultProps = { showFullPrice: false };
showFullPrice: false,
};
componentDidMount() { componentDidMount() {
this.fetchCost(this.props); this.fetchCost(this.props);
@ -35,40 +35,44 @@ class FilePrice extends React.PureComponent<Props> {
} }
fetchCost = (props: Props) => { fetchCost = (props: Props) => {
const { costInfo, fetchCostInfo, uri, fetching, claim } = props; const { costInfo, uri, fetching, claim, doFetchCostInfoForUri } = props;
if (costInfo === undefined && !fetching && claim) { if (uri && costInfo === undefined && !fetching && claim) doFetchCostInfoForUri(uri);
fetchCostInfo(uri);
}
}; };
render() { render() {
const { costInfo, showFullPrice, showLBC, hideFree, claimWasPurchased, type, claimIsMine } = this.props; const {
costInfo,
showFullPrice,
showLBC,
isFiat,
hideFree,
claimWasPurchased,
type,
claimIsMine,
customPrice,
} = this.props;
if (claimIsMine || !costInfo || !costInfo.cost || (!costInfo.cost && hideFree)) { if (!customPrice && (claimIsMine || !costInfo || !costInfo.cost || (!costInfo.cost && hideFree))) return null;
return null;
} const className = classnames(claimWasPurchased ? 'filePrice__key' : 'filePrice', {
'filePrice--filepage': type === 'filepage',
'filePrice--modal': type === 'modal',
});
return claimWasPurchased ? ( return claimWasPurchased ? (
<span <span className={className}>
className={classnames('file-price__key', {
'file-price__key--filepage': type === 'filepage',
'file-price__key--modal': type === 'modal',
})}
>
<Icon icon={ICONS.PURCHASED} size={type === 'filepage' ? 22 : undefined} /> <Icon icon={ICONS.PURCHASED} size={type === 'filepage' ? 22 : undefined} />
</span> </span>
) : ( ) : (
<CreditAmount <CreditAmount
className={classnames('file-price', { amount={costInfo ? costInfo.cost : customPrice}
'file-price--filepage': type === 'filepage', className={className}
'file-price--modal': type === 'modal', isEstimate={!!costInfo && !costInfo.includesData}
})} isFiat={isFiat}
showFree showFree
showLBC={showLBC}
amount={costInfo.cost}
isEstimate={!costInfo.includesData}
showFullPrice={showFullPrice} showFullPrice={showFullPrice}
showLBC={showLBC}
/> />
); );
} }

View file

@ -11,6 +11,8 @@ import classnames from 'classnames';
import CommentMenuList from 'component/commentMenuList'; import CommentMenuList from 'component/commentMenuList';
import Button from 'component/button'; import Button from 'component/button';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import OptimizedImage from 'component/optimizedImage';
import { parseSticker } from 'util/comments';
type Props = { type Props = {
uri: string, uri: string,
@ -45,11 +47,13 @@ function LivestreamComment(props: Props) {
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri; const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
const { claimName } = parseURI(authorUri); const { claimName } = parseURI(authorUri);
const stickerFromMessage = parseSticker(message);
return ( return (
<li <li
className={classnames('livestream-comment', { className={classnames('livestream-comment', {
'livestream-comment--superchat': supportAmount > 0, 'livestream-comment--superchat': supportAmount > 0,
'livestream-comment--sticker': Boolean(stickerFromMessage),
})} })}
> >
{supportAmount > 0 && ( {supportAmount > 0 && (
@ -60,8 +64,12 @@ function LivestreamComment(props: Props) {
)} )}
<div className="livestream-comment__body"> <div className="livestream-comment__body">
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />} {(supportAmount > 0 || Boolean(stickerFromMessage)) && <ChannelThumbnail uri={authorUri} xsmall />}
<div className="livestream-comment__info"> <div
className={classnames('livestream-comment__info', {
'livestream-comment__info--sticker': Boolean(stickerFromMessage),
})}
>
{isGlobalMod && ( {isGlobalMod && (
<Tooltip label={__('Admin')}> <Tooltip label={__('Admin')}>
<span className="comment__badge comment__badge--global-mod"> <span className="comment__badge comment__badge--global-mod">
@ -103,9 +111,15 @@ function LivestreamComment(props: Props) {
</span> </span>
)} )}
<div className="livestream-comment__text"> {stickerFromMessage ? (
<MarkdownPreview content={message} promptLinks stakedLevel={stakedLevel} disableTimestamps /> <div className="sticker__comment">
</div> <OptimizedImage src={stickerFromMessage.url} waitLoad />
</div>
) : (
<div className="livestream-comment__text">
<MarkdownPreview content={message} promptLinks stakedLevel={stakedLevel} disableTimestamps />
</div>
)}
</div> </div>
</div> </div>

View file

@ -10,6 +10,8 @@ import CreditAmount from 'component/common/credit-amount';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import Tooltip from 'component/common/tooltip'; import Tooltip from 'component/common/tooltip';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import OptimizedImage from 'component/optimizedImage';
import { parseSticker } from 'util/comments';
type Props = { type Props = {
uri: string, uri: string,
@ -47,7 +49,7 @@ export default function LivestreamComments(props: Props) {
superChats: superChatsByTipAmount, superChats: superChatsByTipAmount,
} = props; } = props;
let superChatsFiatAmount, superChatsTotalAmount; let superChatsFiatAmount, superChatsLBCAmount, superChatsTotalAmount, hasSuperChats;
const commentsRef = React.createRef(); const commentsRef = React.createRef();
const [viewMode, setViewMode] = React.useState(VIEW_MODE_CHAT); const [viewMode, setViewMode] = React.useState(VIEW_MODE_CHAT);
@ -58,6 +60,8 @@ export default function LivestreamComments(props: Props) {
// which kind of superchat to display, either // which kind of superchat to display, either
const commentsToDisplay = viewMode === VIEW_MODE_CHAT ? commentsByChronologicalOrder : superChatsByTipAmount; const commentsToDisplay = viewMode === VIEW_MODE_CHAT ? commentsByChronologicalOrder : superChatsByTipAmount;
const stickerSuperChats =
superChatsByTipAmount && superChatsByTipAmount.filter(({ comment }) => Boolean(parseSticker(comment)));
const discussionElement = document.querySelector('.livestream__comments'); const discussionElement = document.querySelector('.livestream__comments');
@ -130,7 +134,9 @@ export default function LivestreamComments(props: Props) {
} }
superChatsFiatAmount = fiatAmount; superChatsFiatAmount = fiatAmount;
superChatsTotalAmount = LBCAmount; superChatsLBCAmount = LBCAmount;
superChatsTotalAmount = superChatsFiatAmount + superChatsLBCAmount;
hasSuperChats = (superChatsTotalAmount || 0) > 0;
} }
let superChatsReversed; let superChatsReversed;
@ -160,11 +166,16 @@ export default function LivestreamComments(props: Props) {
return null; return null;
} }
function getStickerUrl(comment: string) {
const stickerFromComment = parseSticker(comment);
return stickerFromComment && stickerFromComment.url;
}
return ( return (
<div className="card livestream__discussion"> <div className="card livestream__discussion">
<div className="card__header--between livestream-discussion__header"> <div className="card__header--between livestream-discussion__header">
<div className="livestream-discussion__title">{__('Live discussion')}</div> <div className="livestream-discussion__title">{__('Live discussion')}</div>
{(superChatsTotalAmount || 0) > 0 && ( {hasSuperChats && (
<div className="recommended-content__toggles"> <div className="recommended-content__toggles">
{/* the superchats in chronological order button */} {/* the superchats in chronological order button */}
<Button <Button
@ -186,7 +197,7 @@ export default function LivestreamComments(props: Props) {
})} })}
label={ label={
<> <>
<CreditAmount amount={superChatsTotalAmount || 0} size={8} /> / <CreditAmount amount={superChatsLBCAmount || 0} size={8} /> /
<CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')} <CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')}
</> </>
} }
@ -207,28 +218,48 @@ export default function LivestreamComments(props: Props) {
</div> </div>
)} )}
<div ref={commentsRef} className="livestream__comments-wrapper"> <div ref={commentsRef} className="livestream__comments-wrapper">
{viewMode === VIEW_MODE_CHAT && superChatsByTipAmount && (superChatsTotalAmount || 0) > 0 && ( {viewMode === VIEW_MODE_CHAT && superChatsByTipAmount && hasSuperChats && (
<div className="livestream-superchats__wrapper"> <div className="livestream-superchats__wrapper">
<div className="livestream-superchats__inner"> <div className="livestream-superchats__inner">
{superChatsByTipAmount.map((superChat: Comment) => ( {superChatsByTipAmount.map((superChat: Comment) => {
<Tooltip key={superChat.comment_id} label={superChat.comment}> const isSticker = stickerSuperChats && stickerSuperChats.includes(superChat);
<div className="livestream-superchat">
<div className="livestream-superchat__thumbnail">
<ChannelThumbnail uri={superChat.channel_url} xsmall />
</div>
<div className="livestream-superchat__info"> const SuperChatWrapper = !isSticker
<UriIndicator uri={superChat.channel_url} link /> ? ({ children }) => <Tooltip label={superChat.comment}>{children}</Tooltip>
<CreditAmount : ({ children }) => <>{children}</>;
size={10}
className="livestream-superchat__amount-large" return (
amount={superChat.support_amount} <SuperChatWrapper key={superChat.comment_id}>
isFiat={superChat.is_fiat} <div className="livestream-superchat">
/> <div className="livestream-superchat__thumbnail">
<ChannelThumbnail uri={superChat.channel_url} xsmall />
</div>
<div
className={classnames('livestream-superchat__info', {
'livestream-superchat__info--sticker': isSticker,
'livestream-superchat__info--not-sticker': stickerSuperChats && !isSticker,
})}
>
<div className="livestream-superchat__info--user">
<UriIndicator uri={superChat.channel_url} link />
<CreditAmount
size={10}
className="livestream-superchat__amount-large"
amount={superChat.support_amount}
isFiat={superChat.is_fiat}
/>
</div>
{stickerSuperChats.includes(superChat) && getStickerUrl(superChat.comment) && (
<div className="livestream-superchat__info--image">
<OptimizedImage src={getStickerUrl(superChat.comment)} waitLoad />
</div>
)}
</div>
</div> </div>
</div> </SuperChatWrapper>
</Tooltip> );
))} })}
</div> </div>
</div> </div>
)} )}

View file

@ -5,36 +5,26 @@ import {
makeSelectClaimIsMine, makeSelectClaimIsMine,
selectFetchingMyChannels, selectFetchingMyChannels,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { selectBalance, selectIsSendingSupport } from 'redux/selectors/wallet'; import { doHideModal } from 'redux/actions/app';
import { doSendTip } from 'redux/actions/wallet'; import { doSendTip, doSendCashTip } from 'redux/actions/wallet';
import * as SETTINGS from 'constants/settings';
import WalletSendTip from './view';
import { doOpenModal, doHideModal } from 'redux/actions/app';
import { withRouter } from 'react-router';
import { makeSelectClientSetting } from 'redux/selectors/settings'; import { makeSelectClientSetting } from 'redux/selectors/settings';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app'; import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doToast } from 'redux/actions/notifications'; import { selectBalance, selectIsSendingSupport } from 'redux/selectors/wallet';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { withRouter } from 'react-router';
import * as SETTINGS from 'constants/settings';
import WalletSendTip from './view';
const select = (state, props) => ({ const select = (state, props) => ({
isPending: selectIsSendingSupport(state), activeChannelClaim: selectActiveChannelClaim(state),
title: makeSelectTitleForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri, false)(state),
balance: selectBalance(state), balance: selectBalance(state),
instantTipEnabled: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_ENABLED)(state), claim: makeSelectClaimForUri(props.uri, false)(state),
instantTipMax: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_MAX)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state), claimIsMine: makeSelectClaimIsMine(props.uri)(state),
fetchingChannels: selectFetchingMyChannels(state), fetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state),
incognito: selectIncognito(state), incognito: selectIncognito(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)), instantTipEnabled: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_ENABLED)(state),
instantTipMax: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_MAX)(state),
isPending: selectIsSendingSupport(state),
title: makeSelectTitleForUri(props.uri)(state),
}); });
const perform = (dispatch) => ({ export default withRouter(connect(select, { doHideModal, doSendTip, doSendCashTip })(WalletSendTip));
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
closeModal: () => dispatch(doHideModal()),
sendSupport: (params, isSupport) => dispatch(doSendTip(params, isSupport)),
doToast: (options) => dispatch(doToast(options)),
});
export default withRouter(connect(select, perform)(WalletSendTip));

View file

@ -1,403 +1,174 @@
// @flow // @flow
import { Form } from 'component/common/form';
import { Lbryio } from 'lbryinc';
import { parseURI } from 'util/lbryURI';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import React from 'react';
import Button from 'component/button'; import Button from 'component/button';
import { FormField, Form } from 'component/common/form';
import { MINIMUM_PUBLISH_BID } from 'constants/claim';
import CreditAmount from 'component/common/credit-amount';
import I18nMessage from 'component/i18nMessage';
import { Lbryio } from 'lbryinc';
import Card from 'component/common/card'; import Card from 'component/common/card';
import classnames from 'classnames';
import ChannelSelector from 'component/channelSelector'; import ChannelSelector from 'component/channelSelector';
import classnames from 'classnames';
import I18nMessage from 'component/i18nMessage';
import LbcSymbol from 'component/common/lbc-symbol'; import LbcSymbol from 'component/common/lbc-symbol';
import { parseURI } from 'util/lbryURI'; import React from 'react';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp'; import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { getStripeEnvironment } from 'util/stripe'; import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment(); const stripeEnvironment = getStripeEnvironment();
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
const MINIMUM_FIAT_TIP = 1;
const MAXIMUM_FIAT_TIP = 1000;
const DEFAULT_TIP_ERROR = __('Sorry, there was an error in processing your payment!');
const TAB_BOOST = 'TabBoost'; const TAB_BOOST = 'TabBoost';
const TAB_FIAT = 'TabFiat'; const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC'; const TAB_LBC = 'TabLBC';
type SupportParams = { amount: number, claim_id: string, channel_id?: string }; type SupportParams = { amount: number, claim_id: string, channel_id?: string };
type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string };
type UserParams = { activeChannelName: ?string, activeChannelId: ?string };
type Props = { type Props = {
uri: string, activeChannelClaim: ?ChannelClaim,
claimIsMine: boolean,
title: string,
claim: StreamClaim,
isPending: boolean,
isSupport: boolean,
sendSupport: (SupportParams, boolean) => void,
closeModal: () => void,
balance: number, balance: number,
claim: StreamClaim,
claimIsMine: boolean,
fetchingChannels: boolean, fetchingChannels: boolean,
incognito: boolean,
instantTipEnabled: boolean, instantTipEnabled: boolean,
instantTipMax: { amount: number, currency: string }, instantTipMax: { amount: number, currency: string },
activeChannelClaim: ?ChannelClaim, isPending: boolean,
incognito: boolean, isSupport: boolean,
doToast: ({ message: string }) => void, title: string,
isAuthenticated: boolean, uri: string,
doHideModal: () => void,
doSendCashTip: (TipParams, boolean, UserParams, string, ?string) => string,
doSendTip: (SupportParams, boolean) => void, // function that comes from lbry-redux
}; };
function WalletSendTip(props: Props) { function WalletSendTip(props: Props) {
const { const {
uri, activeChannelClaim,
title,
isPending,
claimIsMine,
balance, balance,
claim = {}, claim = {},
instantTipEnabled, claimIsMine,
instantTipMax,
sendSupport,
closeModal,
fetchingChannels, fetchingChannels,
incognito, incognito,
activeChannelClaim, instantTipEnabled,
doToast, instantTipMax,
isAuthenticated, isPending,
title,
uri,
doHideModal,
doSendCashTip,
doSendTip,
} = props; } = props;
/** REACT STATE **/ /** STATE **/
const [presetTipAmount, setPresetTipAmount] = usePersistedState('comment-support:presetTip', DEFAULT_TIP_AMOUNTS[0]);
const [customTipAmount, setCustomTipAmount] = usePersistedState('comment-support:customTip', 1.0);
const [useCustomTip, setUseCustomTip] = usePersistedState('comment-support:useCustomTip', false);
const [isConfirming, setIsConfirming] = React.useState(false);
// only allow certain creators to receive tips const [tipAmount, setTipAmount] = usePersistedState('comment-support:customTip', 1.0);
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(); // dont persist because it needs to be calc'd per creator const [isOnConfirmationPage, setConfirmationPage] = React.useState(false);
// show things conditionally based on if a user has a card already
const [hasCardSaved, setHasSavedCard] = usePersistedState('comment-support:hasCardSaved', false);
// show the tip error on the frontend
const [tipError, setTipError] = React.useState(); const [tipError, setTipError] = React.useState();
// denote which tab to show on the frontend
const [activeTab, setActiveTab] = usePersistedState(TAB_BOOST); const [activeTab, setActiveTab] = usePersistedState(TAB_BOOST);
const [disableSubmitButton, setDisableSubmitButton] = React.useState();
// handle default active tab /** CONSTS **/
React.useEffect(() => {
// force to boost tab if it's someone's own upload
if (claimIsMine) {
setActiveTab(TAB_BOOST);
} else {
// or set LBC tip as the default if none is set yet
if (!activeTab || activeTab === 'undefined') {
setActiveTab(TAB_LBC);
}
}
}, []);
// alphanumeric claim id const claimTypeText = getClaimTypeText();
const isSupport = claimIsMine || activeTab === TAB_BOOST;
const titleText = claimIsMine
? __('Boost Your %claimTypeText%', { claimTypeText })
: __('Boost This %claimTypeText%', { claimTypeText });
const { claim_id: claimId } = claim; const { claim_id: claimId } = claim;
let channelName;
// channel name used in url try {
const { channelName } = parseURI(uri); ({ channelName } = parseURI(uri));
} catch (e) {}
const activeChannelName = activeChannelClaim && activeChannelClaim.name; const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id; const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
// setup variables for backend tip API // setup variables for backend tip API
let channelClaimId, tipChannelName; const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id;
// if there is a signing channel it's on a file const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name;
if (claim.signing_channel) {
channelClaimId = claim.signing_channel.claim_id;
tipChannelName = claim.signing_channel.name;
// otherwise it's on the channel page
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
}
const sourceClaimId = claim.claim_id;
// check if creator has a payment method saved
React.useEffect(() => {
if (channelClaimId && isAuthenticated && stripeEnvironment) {
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
).then((customerStatusResponse) => {
const defaultPaymentMethodId =
customerStatusResponse.Customer &&
customerStatusResponse.Customer.invoice_settings &&
customerStatusResponse.Customer.invoice_settings.default_payment_method &&
customerStatusResponse.Customer.invoice_settings.default_payment_method.id;
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}
}, [channelClaimId, isAuthenticated, stripeEnvironment]);
// focus tip element if it exists
React.useEffect(() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
}, []);
// check if user can receive tips
React.useEffect(() => {
if (channelClaimId && stripeEnvironment) {
Lbryio.call(
'account',
'check',
{
channel_claim_id: channelClaimId,
channel_name: tipChannelName,
environment: stripeEnvironment,
},
'post'
)
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
} else {
setCanReceiveFiatTip(false);
}
})
.catch(function (error) {
// console.log(error);
});
}
}, [channelClaimId, stripeEnvironment]);
// if user has no balance, used to show conditional frontend
const noBalance = balance === 0;
// the tip amount, based on if a preset or custom tip amount is being used
const tipAmount = useCustomTip ? customTipAmount : presetTipAmount;
// get type of claim (stream/channel/repost/collection) for display on frontend
function getClaimTypeText() {
if (claim.value_type === 'stream') {
return __('Content');
} else if (claim.value_type === 'channel') {
return __('Channel');
} else if (claim.value_type === 'repost') {
return __('Repost');
} else if (claim.value_type === 'collection') {
return __('List');
} else {
return __('Claim');
}
}
const claimTypeText = getClaimTypeText();
// icon to use or explainer text to show per tab // icon to use or explainer text to show per tab
let iconToUse; let explainerText = '',
let explainerText = ''; confirmLabel = '';
if (activeTab === TAB_BOOST) { switch (activeTab) {
iconToUse = ICONS.LBC; case TAB_BOOST:
explainerText = __('This refundable boost will improve the discoverability of this %claimTypeText% while active.', { explainerText = __(
claimTypeText, 'This refundable boost will improve the discoverability of this %claimTypeText% while active. ',
}); { claimTypeText }
} else if (activeTab === TAB_FIAT) { );
iconToUse = ICONS.FINANCE; confirmLabel = __('Boosting');
explainerText = __('Show this channel your appreciation by sending a donation in USD.'); break;
// if (!hasCardSaved) { case TAB_FIAT:
// explainerText += __('You must add a card to use this functionality.'); explainerText = __('Show this channel your appreciation by sending a donation in USD. ');
// } confirmLabel = __('Tipping Fiat (USD)');
} else if (activeTab === TAB_LBC) { break;
iconToUse = ICONS.LBC; case TAB_LBC:
explainerText = __('Show this channel your appreciation by sending a donation of Credits.'); explainerText = __('Show this channel your appreciation by sending a donation of Credits. ');
confirmLabel = __('Tipping Credit');
break;
} }
const isSupport = claimIsMine || activeTab === TAB_BOOST; /** FUNCTIONS **/
React.useEffect(() => { function getClaimTypeText() {
// Regex for number up to 8 decimal places switch (claim.value_type) {
let regexp; case 'stream':
let tipError; return __('Content');
case 'channel':
if (tipAmount === 0) { return __('Channel');
tipError = __('Amount must be a positive number'); case 'repost':
} else if (!tipAmount || typeof tipAmount !== 'number') { return __('Repost');
tipError = __('Amount must be a number'); case 'collection':
return __('List');
default:
return __('Claim');
} }
}
// if it's not fiat, aka it's boost or lbc tip
else if (activeTab !== TAB_FIAT) {
regexp = RegExp(/^(\d*([.]\d{0,8})?)$/);
const validTipInput = regexp.test(String(tipAmount));
if (!validTipInput) {
tipError = __('Amount must have no more than 8 decimal places');
} else if (!validTipInput) {
tipError = __('Amount must have no more than 8 decimal places');
} else if (tipAmount === balance) {
tipError = __('Please decrease the amount to account for transaction fees');
} else if (tipAmount > balance) {
tipError = __('Not enough Credits');
} else if (tipAmount < MINIMUM_PUBLISH_BID) {
tipError = __('Amount must be higher');
}
// if tip fiat tab
} else {
regexp = RegExp(/^(\d*([.]\d{0,2})?)$/);
const validTipInput = regexp.test(String(tipAmount));
if (!validTipInput) {
tipError = __('Amount must have no more than 2 decimal places');
} else if (tipAmount < MINIMUM_FIAT_TIP) {
tipError = __('Amount must be at least one dollar');
} else if (tipAmount > MAXIMUM_FIAT_TIP) {
tipError = __('Amount cannot be over 1000 dollars');
}
}
setTipError(tipError);
}, [tipAmount, balance, setTipError, activeTab]);
// make call to the backend to send lbc or fiat // make call to the backend to send lbc or fiat
function sendSupportOrConfirm(instantTipMaxAmount = null) { function sendSupportOrConfirm(instantTipMaxAmount = null) {
// send a tip if (!isOnConfirmationPage && (!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)) {
if (!isConfirming && (!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)) { setConfirmationPage(true);
setIsConfirming(true);
} else { } else {
// send a boost const supportParams: SupportParams = {
const supportParams: SupportParams = { amount: tipAmount, claim_id: claimId }; amount: tipAmount,
claim_id: claimId,
// include channel name if donation not anonymous channel_id: activeChannelClaim && !incognito ? activeChannelClaim.claim_id : undefined,
if (activeChannelClaim && !incognito) { };
supportParams.channel_id = activeChannelClaim.claim_id;
}
// send tip/boost // send tip/boost
sendSupport(supportParams, isSupport); doSendTip(supportParams, isSupport);
closeModal(); doHideModal();
} }
} }
// when the form button is clicked // when the form button is clicked
function handleSubmit() { function handleSubmit() {
if (tipAmount && claimId) { if (!tipAmount || !claimId) return;
// send an instant tip (no need to go to an exchange first)
if (instantTipEnabled && activeTab !== TAB_FIAT) {
if (instantTipMax.currency === 'LBC') {
sendSupportOrConfirm(instantTipMax.amount);
} else {
// Need to convert currency of instant purchase maximum before trying to send support
Lbryio.getExchangeRates().then(({ LBC_USD }) => {
sendSupportOrConfirm(instantTipMax.amount / LBC_USD);
});
}
// sending fiat tip
} else if (activeTab === TAB_FIAT) {
if (!isConfirming) {
setIsConfirming(true);
} else if (isConfirming) {
let sendAnonymously = !activeChannelClaim || incognito;
// hit backend to send tip // send an instant tip (no need to go to an exchange first)
Lbryio.call( if (instantTipEnabled && activeTab !== TAB_FIAT) {
'customer', if (instantTipMax.currency === 'LBC') {
'tip', sendSupportOrConfirm(instantTipMax.amount);
{
// round to fix issues with floating point numbers
amount: Math.round(100 * tipAmount), // convert from dollars to cents
creator_channel_name: tipChannelName, // creator_channel_name
creator_channel_claim_id: channelClaimId,
tipper_channel_name: sendAnonymously ? '' : activeChannelName,
tipper_channel_claim_id: sendAnonymously ? '' : activeChannelId,
currency: 'USD',
anonymous: sendAnonymously,
source_claim_id: sourceClaimId,
environment: stripeEnvironment,
},
'post'
)
.then((customerTipResponse) => {
doToast({
message: __("You sent $%amount% as a tip to %tipChannelName%, I'm sure they appreciate it!", {
amount: tipAmount,
tipChannelName,
}),
});
})
.catch(function (error) {
// show error message from Stripe if one exists (being passed from backend by Beamer's API currently)
let displayError;
if (error.message) {
displayError = error.message;
} else {
displayError = DEFAULT_TIP_ERROR;
}
doToast({ message: displayError, isError: true });
});
closeModal();
}
// if it's a boost (?)
} else { } else {
sendSupportOrConfirm(); // Need to convert currency of instant purchase maximum before trying to send support
Lbryio.getExchangeRates().then(({ LBC_USD }) => sendSupportOrConfirm(instantTipMax.amount / LBC_USD));
} }
} // sending fiat tip
} } else if (activeTab === TAB_FIAT) {
if (!isOnConfirmationPage) {
const countDecimals = function (value) { setConfirmationPage(true);
const text = value.toString();
const index = text.indexOf('.');
return text.length - index - 1;
};
function handleCustomPriceChange(event: SyntheticInputEvent<*>) {
let tipAmountAsString = event.target.value;
let tipAmount = parseFloat(tipAmountAsString);
const howManyDecimals = countDecimals(tipAmountAsString);
// fiat tip input
if (activeTab === TAB_FIAT) {
if (Number.isNaN(tipAmount)) {
setCustomTipAmount('');
}
// allow maximum of two decimal places
if (howManyDecimals > 2) {
tipAmount = Math.floor(tipAmount * 100) / 100;
}
// remove decimals, and then get number of digits
const howManyDigits = Math.trunc(tipAmount).toString().length;
if (howManyDigits > 4 && tipAmount !== 1000) {
setTipError('Amount cannot be over 1000 dollars');
setCustomTipAmount(tipAmount);
} else if (tipAmount > 1000) {
setTipError('Amount cannot be over 1000 dollars');
setCustomTipAmount(tipAmount);
} else { } else {
setCustomTipAmount(tipAmount); const tipParams: TipParams = { tipAmount, tipChannelName, channelClaimId };
const userParams: UserParams = { activeChannelName, activeChannelId };
// hit backend to send tip
doSendCashTip(tipParams, !activeChannelClaim || incognito, userParams, claimId, stripeEnvironment);
doHideModal();
} }
// LBC tip input // if it's a boost (?)
} else { } else {
// TODO: this is a bit buggy, needs a touchup sendSupportOrConfirm();
// if (howManyDecimals > 9) {
// // only allows up to 8 decimal places
// tipAmount = Number(tipAmount.toString().match(/^-?\d+(?:\.\d{0,8})?/)[0]);
//
// setTipError('Please only use up to 8 decimals');
// }
setCustomTipAmount(tipAmount);
} }
} }
@ -407,10 +178,7 @@ function WalletSendTip(props: Props) {
// testing for NaN ES5 style https://stackoverflow.com/a/35912757/3973137 // testing for NaN ES5 style https://stackoverflow.com/a/35912757/3973137
// also sometimes it's returned as a string // also sometimes it's returned as a string
// eslint-disable-next-line // eslint-disable-next-line
if (tipAmount !== tipAmount || tipAmount === 'NaN') { return tipAmount !== tipAmount || tipAmount === 'NaN';
return true;
}
return false;
} }
function convertToTwoDecimals(number) { function convertToTwoDecimals(number) {
@ -423,129 +191,65 @@ function WalletSendTip(props: Props) {
const displayAmount = !isNan(tipAmount) ? amountToShow : ''; const displayAmount = !isNan(tipAmount) ? amountToShow : '';
// build button text based on tab // build button text based on tab
if (activeTab === TAB_BOOST) { switch (activeTab) {
return claimIsMine case TAB_BOOST:
? __('Boost Your %claimTypeText%', { claimTypeText }) return titleText;
: __('Boost This %claimTypeText%', { claimTypeText }); case TAB_FIAT:
} else if (activeTab === TAB_FIAT) { return __('Send a $%displayAmount% Tip', { displayAmount });
return __('Send a $%displayAmount% Tip', { displayAmount }); case TAB_LBC:
} else if (activeTab === TAB_LBC) { return __('Send a %displayAmount% Credit Tip', { displayAmount });
return __('Send a %displayAmount% Credit Tip', { displayAmount });
} }
} }
// dont allow user to click send button /** RENDER **/
function shouldDisableAmountSelector(amount) {
return (
(amount > balance && activeTab !== TAB_FIAT) || (activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
);
}
// showed on confirm page above amount const getTabButton = (tabIcon: string, tabLabel: string, tabName: string) => (
function setConfirmLabel() { <Button
if (activeTab === TAB_LBC) { key={tabName}
return __('Tipping Credit'); icon={tabIcon}
} else if (activeTab === TAB_FIAT) { label={tabLabel}
return __('Tipping Fiat (USD)'); button="alt"
} else if (activeTab === TAB_BOOST) { onClick={() => {
return __('Boosting'); const tipInputElement = document.getElementById('tip-input');
} if (tipInputElement) tipInputElement.focus();
} if (!isOnConfirmationPage) setActiveTab(tabName);
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === tabName })}
/>
);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
{/* if there is no LBC balance, show user frontend to get credits */} {/* if there is no LBC balance, show user frontend to get credits */}
{/* if there is lbc, the main tip/boost gui with the 3 tabs at the top */} {/* if there is lbc, the main tip/boost gui with the 3 tabs at the top */}
<Card <Card
title={ title={<LbcSymbol postfix={titleText} size={22} />}
<LbcSymbol
postfix={
claimIsMine
? __('Boost Your %claimTypeText%', { claimTypeText })
: __('Support This %claimTypeText%', { claimTypeText })
}
size={22}
/>
}
subtitle={ subtitle={
<React.Fragment> <>
{!claimIsMine && ( {!claimIsMine && (
<div className="section"> <div className="section">
{/* tip LBC tab button */} {/* tip LBC tab button */}
<Button {getTabButton(ICONS.LBC, __('Tip'), TAB_LBC)}
key="tip"
icon={ICONS.LBC}
label={__('Tip')}
button="alt"
onClick={() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
if (!isConfirming) {
setActiveTab(TAB_LBC);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_LBC })}
/>
{/* tip fiat tab button */} {/* tip fiat tab button */}
{/* @if TARGET='web' */} {stripeEnvironment && getTabButton(ICONS.FINANCE, __('Tip'), TAB_FIAT)}
{stripeEnvironment && (
<Button {/* support LBC tab button */}
key="tip-fiat" {getTabButton(ICONS.TRENDING, __('Boost'), TAB_BOOST)}
icon={ICONS.FINANCE}
label={__('Tip')}
button="alt"
onClick={() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
if (!isConfirming) {
setActiveTab(TAB_FIAT);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_FIAT })}
/>
)}
{/* @endif */}
{/* tip LBC tab button */}
<Button
key="boost"
icon={ICONS.TRENDING}
label={__('Boost')}
button="alt"
onClick={() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
if (!isConfirming) {
setActiveTab(TAB_BOOST);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_BOOST })}
/>
</div> </div>
)} )}
{/* short explainer under the button */} {/* short explainer under the button */}
<div className="section__subtitle"> <div className="section__subtitle">
{explainerText + ' '} {explainerText}
{/* {activeTab === TAB_FIAT && !hasCardSaved && <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add A Card')} button="link" />} */} {/* {activeTab === TAB_FIAT && !hasCardSaved && <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add A Card')} button="link" />} */}
{ <Button label={__('Learn more')} button="link" href="https://lbry.com/faq/tipping" />
<Button
label={__('Learn more')}
button="link"
href="https://odysee.com/@OdyseeHelp:b/Monetization-of-Content:3"
/>
}
</div> </div>
</React.Fragment> </>
} }
actions={ actions={
// confirmation modal, allow user to confirm or cancel transaction // confirmation modal, allow user to confirm or cancel transaction
isConfirming ? ( isOnConfirmationPage ? (
<> <>
<div className="section section--padded card--inline confirm__wrapper"> <div className="section section--padded card--inline confirm__wrapper">
<div className="section"> <div className="section">
@ -555,10 +259,10 @@ function WalletSendTip(props: Props) {
<div className="confirm__value"> <div className="confirm__value">
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')} {activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
</div> </div>
<div className="confirm__label">{setConfirmLabel()}</div> <div className="confirm__label">{confirmLabel}</div>
<div className="confirm__value"> <div className="confirm__value">
{activeTab === TAB_FIAT ? ( {activeTab === TAB_FIAT ? (
<p>$ {(Math.round(tipAmount * 100) / 100).toFixed(2)}</p> <p>{`$ ${(Math.round(tipAmount * 100) / 100).toFixed(2)}`}</p>
) : ( ) : (
<LbcSymbol postfix={tipAmount} size={22} /> <LbcSymbol postfix={tipAmount} size={22} />
)} )}
@ -567,98 +271,23 @@ function WalletSendTip(props: Props) {
</div> </div>
<div className="section__actions"> <div className="section__actions">
<Button autoFocus onClick={handleSubmit} button="primary" disabled={isPending} label={__('Confirm')} /> <Button autoFocus onClick={handleSubmit} button="primary" disabled={isPending} label={__('Confirm')} />
<Button button="link" label={__('Cancel')} onClick={() => setIsConfirming(false)} /> <Button button="link" label={__('Cancel')} onClick={() => setConfirmationPage(false)} />
</div> </div>
</> </>
) : !((activeTab === TAB_LBC || activeTab === TAB_BOOST) && noBalance) ? ( ) : !((activeTab === TAB_LBC || activeTab === TAB_BOOST) && balance === 0) ? (
<> <>
<div className="section"> <ChannelSelector />
<ChannelSelector />
</div>
{/* prompt to save a card */}
{activeTab === TAB_FIAT && !hasCardSaved && (
<h3 className="add-card-prompt">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" />{' '}
{__('To Tip Creators')}
</h3>
)}
{/* section to pick tip/boost amount */} {/* section to pick tip/boost amount */}
<div className="section"> <WalletTipAmountSelector
{DEFAULT_TIP_AMOUNTS.map((amount) => ( setTipError={setTipError}
<Button tipError={tipError}
key={amount} claim={claim}
disabled={shouldDisableAmountSelector(amount)} activeTab={activeTab === TAB_BOOST ? TAB_LBC : activeTab}
button="alt" amount={tipAmount}
className={classnames('button-toggle button-toggle--expandformobile', { onChange={(amount) => setTipAmount(amount)}
'button-toggle--active': tipAmount === amount && !useCustomTip, setDisableSubmitButton={setDisableSubmitButton}
'button-toggle--disabled': amount > balance, />
})}
label={amount}
icon={iconToUse}
onClick={() => {
setPresetTipAmount(amount);
setUseCustomTip(false);
}}
/>
))}
<Button
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': useCustomTip, // set as active
})}
icon={iconToUse}
label={__('Custom')}
onClick={() => setUseCustomTip(true)}
// disabled if it's receive fiat and there is no card or creator can't receive tips
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)}
/>
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && activeTab !== TAB_FIAT && (
<Button
button="secondary"
className="button-toggle-group-action"
icon={ICONS.BUY}
title={__('Buy or swap more LBRY Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
)}
</div>
{useCustomTip && (
<div className="section">
<FormField
autoFocus
name="tip-input"
label={
<React.Fragment>
{__('Custom support amount')}{' '}
{activeTab !== TAB_FIAT ? (
<I18nMessage
tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} showLBC={false} /> }}
>
(%lbc_balance% Credits available)
</I18nMessage>
) : (
'in USD'
)}
</React.Fragment>
}
error={tipError}
min="0"
step="any"
type="number"
style={{
width: activeTab === TAB_FIAT ? '99px' : '160px',
}}
placeholder="1.23"
value={customTipAmount}
onChange={(event) => handleCustomPriceChange(event)}
/>
</div>
)}
{/* send tip/boost button */} {/* send tip/boost button */}
<div className="section__actions"> <div className="section__actions">
@ -667,35 +296,25 @@ function WalletSendTip(props: Props) {
icon={isSupport ? ICONS.TRENDING : ICONS.SUPPORT} icon={isSupport ? ICONS.TRENDING : ICONS.SUPPORT}
button="primary" button="primary"
type="submit" type="submit"
disabled={ disabled={fetchingChannels || isPending || tipError || !tipAmount || disableSubmitButton}
fetchingChannels ||
isPending ||
tipError ||
!tipAmount ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
}
label={buildButtonText()} label={buildButtonText()}
/> />
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>} {fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</div> </div>
{activeTab !== TAB_FIAT ? (
<WalletSpendableBalanceHelp />
) : !canReceiveFiatTip ? (
<div className="help">{__('Only creators that verify cash accounts can receive tips')}</div>
) : (
<div className="help">{__('The payment will be made from your saved card')}</div>
)}
</> </>
) : ( ) : (
// if it's LBC and there is no balance, you can prompt to purchase LBC // if it's LBC and there is no balance, you can prompt to purchase LBC
<Card <Card
title={ title={
<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Supporting content requires %lbc%</I18nMessage> <I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>
{__('Supporting content requires %lbc%')}
</I18nMessage>
} }
subtitle={ subtitle={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}> <I18nMessage tokens={{ lbc: <LbcSymbol /> }}>
With %lbc%, you can send tips to your favorite creators, or help boost their content for more people {__(
to see. 'With %lbc%, you can send tips to your favorite creators, or help boost their content for more people to see.'
)}
</I18nMessage> </I18nMessage>
} }
actions={ actions={
@ -712,7 +331,7 @@ function WalletSendTip(props: Props) {
label={__('Buy/Swap Credits')} label={__('Buy/Swap Credits')}
navigate={`/$/${PAGES.BUY}`} navigate={`/$/${PAGES.BUY}`}
/> />
<Button button="link" label={__('Nevermind')} onClick={closeModal} /> <Button button="link" label={__('Nevermind')} onClick={doHideModal} />
</div> </div>
} }
/> />

View file

@ -2,8 +2,6 @@ import { connect } from 'react-redux';
import { selectBalance } from 'redux/selectors/wallet'; import { selectBalance } from 'redux/selectors/wallet';
import WalletSpendableBalanceHelp from './view'; import WalletSpendableBalanceHelp from './view';
const select = (state) => ({ const select = (state) => ({ balance: selectBalance(state) });
balance: selectBalance(state),
});
export default connect(select)(WalletSpendableBalanceHelp); export default connect(select)(WalletSpendableBalanceHelp);

View file

@ -1,33 +1,21 @@
// @flow // @flow
import React from 'react';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import React from 'react';
type Props = { type Props = { balance: number, inline?: boolean };
balance: number,
inline?: boolean,
};
function WalletSpendableBalanceHelp(props: Props) { function WalletSpendableBalanceHelp(props: Props) {
const { balance, inline } = props; const { balance, inline } = props;
if (!balance) { const getMessage = (text: string) => (
return null; <I18nMessage tokens={{ balance: <CreditAmount amount={balance} precision={4} /> }}>{text}</I18nMessage>
} );
return inline ? ( return !balance ? null : inline ? (
<span className="help--spendable"> <span className="help--spendable">{getMessage(__('%balance% available.'))}</span>
<I18nMessage tokens={{ balance: <CreditAmount amount={balance} precision={4} /> }}>
%balance% available.
</I18nMessage>
</span>
) : ( ) : (
<div className="help"> <div className="help">{getMessage(__('Your immediately spendable balance is %balance%.'))}</div>
<I18nMessage tokens={{ balance: <CreditAmount amount={balance} precision={4} /> }}>
Your immediately spendable balance is %balance%.
</I18nMessage>
</div>
); );
} }

View file

@ -1,13 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectBalance } from 'redux/selectors/wallet'; import { selectBalance } from 'redux/selectors/wallet';
import WalletTipAmountSelector from './view'; import WalletTipAmountSelector from './view';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
const select = (state, props) => ({ const select = (state) => ({ balance: selectBalance(state) });
balance: selectBalance(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
// claim: makeSelectClaimForUri(props.uri)(state),
// claim: makeSelectClaimForUri(props.uri, false)(state),
});
export default connect(select)(WalletTipAmountSelector); export default connect(select)(WalletTipAmountSelector);

View file

@ -1,175 +1,203 @@
// @flow // @flow
import 'scss/component/_wallet-tip-selector.scss';
import { FormField } from 'component/common/form';
import { Lbryio } from 'lbryinc';
import { MINIMUM_PUBLISH_BID } from 'constants/claim';
import { useIsMobile } from 'effects/use-screensize';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import React from 'react';
import Button from 'component/button'; import Button from 'component/button';
import { FormField } from 'component/common/form';
import { MINIMUM_PUBLISH_BID } from 'constants/claim';
import CreditAmount from 'component/common/credit-amount';
import I18nMessage from 'component/i18nMessage';
import classnames from 'classnames'; import classnames from 'classnames';
import React from 'react';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp'; import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe'; import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment(); const stripeEnvironment = getStripeEnvironment();
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100]; const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
const TAB_FIAT = 'TabFiat'; const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC'; const TAB_LBC = 'TabLBC';
type Props = { type Props = {
balance: number,
amount: number,
onChange: (number) => void,
isAuthenticated: boolean,
claim: StreamClaim,
uri: string,
onTipErrorChange: (string) => void,
activeTab: string, activeTab: string,
shouldDisableReviewButton: (boolean) => void, amount: number,
balance: number,
claim: StreamClaim,
convertedAmount?: number,
customTipAmount?: number,
fiatConversion?: boolean,
tipError: boolean,
tipError: string,
uri: string,
onChange: (number) => void,
setConvertedAmount?: (number) => void,
setDisableSubmitButton: (boolean) => void,
setTipError: (any) => void,
}; };
function WalletTipAmountSelector(props: Props) { function WalletTipAmountSelector(props: Props) {
const { balance, amount, onChange, activeTab, claim, onTipErrorChange, shouldDisableReviewButton } = props; const {
const [useCustomTip, setUseCustomTip] = usePersistedState('comment-support:useCustomTip', false); activeTab,
const [tipError, setTipError] = React.useState(); amount,
balance,
claim,
convertedAmount,
customTipAmount,
fiatConversion,
tipError,
onChange,
setConvertedAmount,
setDisableSubmitButton,
setTipError,
} = props;
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(); // dont persist because it needs to be calc'd per creator const isMobile = useIsMobile();
const [useCustomTip, setUseCustomTip] = usePersistedState('comment-support:useCustomTip', true);
const [hasCardSaved, setHasSavedCard] = usePersistedState('comment-support:hasCardSaved', false); const [hasCardSaved, setHasSavedCard] = usePersistedState('comment-support:hasCardSaved', false);
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(); // dont persist because it needs to be calc'd per creator
const [exchangeRate, setExchangeRate] = React.useState();
const tipAmountsToDisplay =
customTipAmount && fiatConversion && activeTab === TAB_FIAT ? [customTipAmount] : DEFAULT_TIP_AMOUNTS;
// if it's fiat but there's no card saved OR the creator can't receive fiat tips // if it's fiat but there's no card saved OR the creator can't receive fiat tips
const shouldDisableFiatSelectors = activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip); const shouldDisableFiatSelectors = activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip);
if (setDisableSubmitButton) setDisableSubmitButton(shouldDisableFiatSelectors);
// setup variables for tip API
const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id;
const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name;
/** /**
* whether tip amount selection/review functionality should be disabled * whether tip amount selection/review functionality should be disabled
* @param [amount] LBC amount (optional) * @param [amount] LBC amount (optional)
* @returns {boolean} * @returns {boolean}
*/ */
function shouldDisableAmountSelector(amount) { function shouldDisableAmountSelector(amount: number) {
// if it's LBC but the balance isn't enough, or fiat conditions met // if it's LBC but the balance isn't enough, or fiat conditions met
// $FlowFixMe // $FlowFixMe
return (amount > balance && activeTab !== TAB_FIAT) || shouldDisableFiatSelectors; return (
((amount > balance || balance === 0) && activeTab !== TAB_FIAT) ||
shouldDisableFiatSelectors ||
(customTipAmount && fiatConversion && activeTab !== TAB_FIAT && exchangeRate
? amount * exchangeRate < customTipAmount
: customTipAmount && amount < customTipAmount)
);
} }
shouldDisableReviewButton(shouldDisableFiatSelectors);
// setup variables for tip API
let channelClaimId, tipChannelName;
// if there is a signing channel it's on a file
if (claim.signing_channel) {
channelClaimId = claim.signing_channel.claim_id;
tipChannelName = claim.signing_channel.name;
// otherwise it's on the channel page
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
}
// check if creator has a payment method saved
React.useEffect(() => {
if (stripeEnvironment) {
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
).then((customerStatusResponse) => {
const defaultPaymentMethodId =
customerStatusResponse.Customer &&
customerStatusResponse.Customer.invoice_settings &&
customerStatusResponse.Customer.invoice_settings.default_payment_method &&
customerStatusResponse.Customer.invoice_settings.default_payment_method.id;
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}
}, [stripeEnvironment]);
//
React.useEffect(() => {
if (stripeEnvironment) {
Lbryio.call(
'account',
'check',
{
channel_claim_id: channelClaimId,
channel_name: tipChannelName,
environment: stripeEnvironment,
},
'post'
)
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
}
})
.catch(function (error) {
// console.log(error);
});
}
}, [stripeEnvironment]);
React.useEffect(() => {
// setHasSavedCard(false);
// setCanReceiveFiatTip(true);
let regexp,
tipError = '';
if (amount === 0) {
tipError = __('Amount must be a positive number');
} else if (!amount || typeof amount !== 'number') {
tipError = __('Amount must be a number');
}
// if it's not fiat, aka it's boost or lbc tip
else if (activeTab !== TAB_FIAT) {
regexp = RegExp(/^(\d*([.]\d{0,8})?)$/);
const validTipInput = regexp.test(String(amount));
if (!validTipInput) {
tipError = __('Amount must have no more than 8 decimal places');
} else if (amount === balance) {
tipError = __('Please decrease the amount to account for transaction fees');
} else if (amount > balance) {
tipError = __('Not enough Credits');
} else if (amount < MINIMUM_PUBLISH_BID) {
tipError = __('Amount must be higher');
}
// if tip fiat tab
} else {
regexp = RegExp(/^(\d*([.]\d{0,2})?)$/);
const validTipInput = regexp.test(String(amount));
if (!validTipInput) {
tipError = __('Amount must have no more than 2 decimal places');
} else if (amount < 1) {
tipError = __('Amount must be at least one dollar');
} else if (amount > 1000) {
tipError = __('Amount cannot be over 1000 dollars');
}
}
setTipError(tipError);
onTipErrorChange(tipError);
}, [amount, balance, setTipError, activeTab]);
// parse number as float and sets it in the parent component // parse number as float and sets it in the parent component
function handleCustomPriceChange(amount: number) { function handleCustomPriceChange(amount: number) {
const tipAmount = parseFloat(amount); const tipAmountValue = parseFloat(amount);
onChange(tipAmountValue);
onChange(tipAmount); if (fiatConversion && exchangeRate && setConvertedAmount && convertedAmount !== tipAmountValue * exchangeRate) {
setConvertedAmount(tipAmountValue * exchangeRate);
}
} }
function convertToTwoDecimals(number: number) {
return (Math.round(number * 100) / 100).toFixed(2);
}
React.useEffect(() => {
if (!exchangeRate) {
Lbryio.getExchangeRates().then(({ LBC_USD }) => setExchangeRate(LBC_USD));
} else if ((!convertedAmount || convertedAmount !== amount * exchangeRate) && setConvertedAmount) {
setConvertedAmount(amount * exchangeRate);
}
}, [amount, convertedAmount, exchangeRate, setConvertedAmount]);
// check if creator has a payment method saved
React.useEffect(() => {
if (!stripeEnvironment) return;
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
).then((customerStatusResponse) => {
const defaultPaymentMethodId =
customerStatusResponse.Customer &&
customerStatusResponse.Customer.invoice_settings &&
customerStatusResponse.Customer.invoice_settings.default_payment_method &&
customerStatusResponse.Customer.invoice_settings.default_payment_method.id;
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}, [setHasSavedCard]);
React.useEffect(() => {
if (!stripeEnvironment) return;
Lbryio.call(
'account',
'check',
{
channel_claim_id: channelClaimId,
channel_name: tipChannelName,
environment: stripeEnvironment,
},
'post'
)
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
}
})
.catch(() => {});
}, [canReceiveFiatTip, channelClaimId, tipChannelName]);
React.useEffect(() => {
let regexp;
if (amount === 0) {
setTipError(__('Amount cannot be zero.'));
} else if (!amount || typeof amount !== 'number') {
setTipError(__('Amount must be a number.'));
} else {
// if it's not fiat, aka it's boost or lbc tip
if (activeTab !== TAB_FIAT) {
regexp = RegExp(/^(\d*([.]\d{0,8})?)$/);
const validTipInput = regexp.test(String(amount));
if (!validTipInput) {
setTipError(__('Amount must have no more than 8 decimal places'));
} else if (amount === balance) {
setTipError(__('Please decrease the amount to account for transaction fees'));
} else if (amount > balance || balance === 0) {
setTipError(__('Not enough Credits'));
} else if (amount < MINIMUM_PUBLISH_BID) {
setTipError(__('Amount must be higher'));
} else {
setTipError(false);
}
// if tip fiat tab
} else {
regexp = RegExp(/^(\d*([.]\d{0,2})?)$/);
const validTipInput = regexp.test(String(amount));
if (!validTipInput) {
setTipError(__('Amount must have no more than 2 decimal places'));
} else if (amount < 1) {
setTipError(__('Amount must be at least one dollar'));
} else if (amount > 1000) {
setTipError(__('Amount cannot be over 1000 dollars'));
} else {
setTipError(false);
}
}
}
}, [activeTab, amount, balance, setTipError]);
const getHelpMessage = (helpMessage: any) => <div className="help">{helpMessage}</div>;
return ( return (
<> <>
<div className="section"> <div className="section">
{DEFAULT_TIP_AMOUNTS.map((defaultAmount) => ( {tipAmountsToDisplay.map((defaultAmount) => (
<Button <Button
key={defaultAmount} key={defaultAmount}
disabled={shouldDisableAmountSelector(defaultAmount)} disabled={shouldDisableAmountSelector(defaultAmount)}
@ -186,9 +214,10 @@ function WalletTipAmountSelector(props: Props) {
}} }}
/> />
))} ))}
<Button <Button
button="alt" button="alt"
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)} disabled={shouldDisableFiatSelectors}
className={classnames('button-toggle button-toggle--expandformobile', { className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': useCustomTip, 'button-toggle--active': useCustomTip,
})} })}
@ -207,60 +236,26 @@ function WalletTipAmountSelector(props: Props) {
)} )}
</div> </div>
{useCustomTip && activeTab === TAB_FIAT && !hasCardSaved && ( {customTipAmount &&
<> fiatConversion &&
<div className="help"> activeTab !== TAB_FIAT &&
<span className="help--spendable"> getHelpMessage(
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card ')} button="link" /> To{' '} __(
{__('Tip Creators')} `This support is priced in $USD. ${
</span> convertedAmount
</div> ? __(`The current exchange rate for the submitted amount is: $${convertToTwoDecimals(convertedAmount)}`)
</> : ''
)} }`
)
{/* has card saved but cant creator cant receive tips */} )}
{useCustomTip && activeTab === TAB_FIAT && hasCardSaved && !canReceiveFiatTip && (
<>
<div className="help">
<span className="help--spendable">Only creators that verify cash accounts can receive tips</span>
</div>
</>
)}
{/* has card saved but cant creator cant receive tips */}
{useCustomTip && activeTab === TAB_FIAT && hasCardSaved && canReceiveFiatTip && (
<>
<div className="help">
<span className="help--spendable">Send a tip directly from your attached card</span>
</div>
</>
)}
{/* custom number input form */} {/* custom number input form */}
{useCustomTip && ( {useCustomTip && (
<div className="comment__tip-input"> <div className="walletTipSelector__input">
<FormField <FormField
autoFocus autoFocus={!isMobile}
name="tip-input" name="tip-input"
disabled={shouldDisableAmountSelector()} disabled={!customTipAmount && shouldDisableAmountSelector(0)}
label={
activeTab === TAB_LBC ? (
<React.Fragment>
{__('Custom support amount')}{' '}
<I18nMessage tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} /> }}>
(%lbc_balance% available)
</I18nMessage>
</React.Fragment>
) : (
<></>
)
// <>
// <div className="">
// <span className="help--spendable">Send a tip directly from your attached card</span>
// </div>
// </>
}
error={tipError} error={tipError}
min="0" min="0"
step="any" step="any"
@ -274,35 +269,17 @@ function WalletTipAmountSelector(props: Props) {
{/* lbc tab */} {/* lbc tab */}
{activeTab === TAB_LBC && <WalletSpendableBalanceHelp />} {activeTab === TAB_LBC && <WalletSpendableBalanceHelp />}
{/* fiat button but no card saved */} {activeTab === TAB_FIAT &&
{!useCustomTip && activeTab === TAB_FIAT && !hasCardSaved && ( (!hasCardSaved
<> ? getHelpMessage(
<div className="help"> <>
<span className="help--spendable"> <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" />
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card ')} button="link" /> To{' '} {__(' To Tip Creators')}
{__('Tip Creators')} </>
</span> )
</div> : !canReceiveFiatTip
</> ? getHelpMessage(__('Only creators that verify cash accounts can receive tips'))
)} : getHelpMessage(__('Send a tip directly from your attached card')))}
{/* has card saved but cant creator cant receive tips */}
{!useCustomTip && activeTab === TAB_FIAT && hasCardSaved && !canReceiveFiatTip && (
<>
<div className="help">
<span className="help--spendable">Only creators that verify cash accounts can receive tips</span>
</div>
</>
)}
{/* has card saved but cant creator cant receive tips */}
{!useCustomTip && activeTab === TAB_FIAT && hasCardSaved && canReceiveFiatTip && (
<>
<div className="help">
<span className="help--spendable">Send a tip directly from your attached card</span>
</div>
</>
)}
</> </>
); );
} }

107
ui/constants/stickers.js Normal file
View file

@ -0,0 +1,107 @@
// @flow
const buildCDNUrl = (path: string) => `https://static.odycdn.com/stickers/${path}`;
const buildSticker = (name: string, path: string, price?: number) => ({
name: __(`:${name}:`),
url: buildCDNUrl(path),
price: price,
});
const CAT_BORDER = 'CAT/PNG/cat_with_border.png';
const FAIL_BORDER = 'FAIL/PNG/fail_with_border.png';
const HYPE_BORDER = 'HYPE/PNG/hype_with_border.png';
const PANTS_1_WITH_FRAME = 'PANTS/PNG/PANTS_1_with_frame.png';
const PANTS_2_WITH_FRAME = 'PANTS/PNG/PANTS_2_with_frame.png';
const PISS = 'PISS/PNG/piss_with_frame.png';
const PREGNANT_MAN_ASIA_WHITE_BORDER = 'pregnant%20man/png/Pregnant%20man_white%20border_asia.png';
const PREGNANT_MAN_BLACK_HAIR_WHITE_BORDER = 'pregnant%20man/png/Pregnant%20man_white%20border_black%20hair.png';
const PREGNANT_MAN_BLACK_SKIN_WHITE_BORDER = 'pregnant%20man/png/Pregnant%20man_white%20border_black%20skin.png';
const PREGNANT_MAN_BLONDE_WHITE_BORDER = 'pregnant%20man/png/Pregnant%20man_white%20border_blondie.png';
const PREGNANT_MAN_RED_HAIR_WHITE_BORDER =
'pregnant%20man/png/Pregnant%20man_white%20border_red%20hair%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.png';
const PREGNANT_WOMAN_BLACK_HAIR_GREEN_SHIRT_WHITE_BORDER =
'pregnant%20woman/png/Pregnant%20woman_white_border_black%20hair%20green%20shirt.png';
const PREGNANT_WOMAN_BLACK_HAIR_WHITE_BORDER = 'pregnant%20woman/png/Pregnant%20woman_white_border_black%20hair.png';
const PREGNANT_WOMAN_BLACK_SKIN_WHITE_BORDER = 'pregnant%20woman/png/Pregnant%20woman_white_border_black%20woman.png';
const PREGNANT_WOMAN_BLONDE_WHITE_BORDER = 'pregnant%20woman/png/Pregnant%20woman_white_border_blondie.png';
const PREGNANT_WOMAN_BROWN_HAIR_WHITE_BORDER = 'pregnant%20woman/png/Pregnant%20woman_white_border_brown%20hair.png';
const PREGNANT_WOMAN_RED_HAIR_WHITE_BORDER =
'pregnant%20woman/png/Pregnant%20woman_white_border_red%20hair%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.png';
const ROCKET_SPACEMAN_WITH_BORDER = 'ROCKET%20SPACEMAN/PNG/rocket-spaceman_with-border.png';
const SALTY = 'SALTY/PNG/salty.png';
const SICK_2_WITH_BORDER = 'SICK/PNG/sick2_with_border.png';
const SICK_1_WITH_BORDERDARK_WITH_FRAME = 'SICK/PNG/with%20borderdark%20with%20frame.png';
const SLIME_WITH_FRAME = 'SLIME/PNG/slime_with_frame.png';
const SPHAGETTI_BATH_WITH_FRAME = 'SPHAGETTI%20BATH/PNG/sphagetti%20bath_with_frame.png';
const THUG_LIFE_WITH_BORDER = 'THUG%20LIFE/PNG/thug_life_with_border_clean.png';
const WHUUT_WITH_FRAME = 'WHUUT/PNG/whuut_with-frame.png';
const COMET_TIP = 'TIPS/png/$%20comet%20tip%20with%20border.png';
const BIG_LBC_TIP = 'TIPS/png/big_LBC_TIPV.png';
const BIG_TIP = 'TIPS/png/with%20borderbig$tip.png';
const BITE_TIP = 'TIPS/png/bite_$tip_with%20border.png';
const BITE_TIP_CLOSEUP = 'TIPS/png/bite_$tip_closeup.png';
const FORTUNE_CHEST_LBC = 'TIPS/png/with%20borderfortunechest_LBC_tip.png';
const FORTUNE_CHEST = 'TIPS/png/with%20borderfortunechest$_tip.png';
const LARGE_LBC_TIP = 'TIPS/png/with%20borderlarge_LBC_tip%20.png';
const LARGE_TIP = 'TIPS/png/with%20borderlarge$tip.png';
const BITE_LBC_CLOSEUP = 'TIPS/png/LBC%20bite.png';
const LBC_COMET_TIP = 'TIPS/png/LBC%20comet%20tip%20with%20border.png';
const MEDIUM_LBC_TIP = 'TIPS/png/with%20bordermedium_LBC_tip%20%20%20%20%20%20%20%20%20%20.png';
const MEDIUM_TIP = 'TIPS/png/with%20bordermedium$_%20tip.png';
const SILVER_ODYSEE_COIN = 'TIPS/png/with%20bordersilver_odysee_coinv.png';
const SMALL_LBC_TIP = 'TIPS/png/with%20bordersmall_LBC_tip%20.png';
const SMALL_TIP = 'TIPS/png/with%20bordersmall$_tip.png';
const TIP_HAND_FLIP = 'TIPS/png/tip_hand_flip_$%20_with_border.png';
const TIP_HAND_FLIP_COIN = 'TIPS/png/tip_hand_flip_coin_with_border.png';
const TIP_HAND_FLIP_LBC = 'TIPS/png/tip_hand_flip_lbc_with_border.png';
export const FREE_GLOBAL_STICKERS = [
buildSticker('CAT', CAT_BORDER),
buildSticker('FAIL', FAIL_BORDER),
buildSticker('HYPE', HYPE_BORDER),
buildSticker('PANTS_1', PANTS_1_WITH_FRAME),
buildSticker('PANTS_2', PANTS_2_WITH_FRAME),
buildSticker('PISS', PISS),
buildSticker('PREGNANT_MAN_ASIA', PREGNANT_MAN_ASIA_WHITE_BORDER),
buildSticker('PREGNANT_MAN_BLACK_HAIR', PREGNANT_MAN_BLACK_HAIR_WHITE_BORDER),
buildSticker('PREGNANT_MAN_BLACK_SKIN', PREGNANT_MAN_BLACK_SKIN_WHITE_BORDER),
buildSticker('PREGNANT_MAN_BLONDE', PREGNANT_MAN_BLONDE_WHITE_BORDER),
buildSticker('PREGNANT_MAN_RED_HAIR', PREGNANT_MAN_RED_HAIR_WHITE_BORDER),
buildSticker('PREGNANT_WOMAN_BLACK_HAIR_GREEN_SHIRT', PREGNANT_WOMAN_BLACK_HAIR_GREEN_SHIRT_WHITE_BORDER),
buildSticker('PREGNANT_WOMAN_BLACK_HAIR', PREGNANT_WOMAN_BLACK_HAIR_WHITE_BORDER),
buildSticker('PREGNANT_WOMAN_BLACK_SKIN', PREGNANT_WOMAN_BLACK_SKIN_WHITE_BORDER),
buildSticker('PREGNANT_WOMAN_BLONDE', PREGNANT_WOMAN_BLONDE_WHITE_BORDER),
buildSticker('PREGNANT_WOMAN_BROWN_HAIR', PREGNANT_WOMAN_BROWN_HAIR_WHITE_BORDER),
buildSticker('PREGNANT_WOMAN_RED_HAIR', PREGNANT_WOMAN_RED_HAIR_WHITE_BORDER),
buildSticker('ROCKET_SPACEMAN', ROCKET_SPACEMAN_WITH_BORDER),
buildSticker('SALTY', SALTY),
buildSticker('SICK_FLAME', SICK_2_WITH_BORDER),
buildSticker('SICK_SKULL', SICK_1_WITH_BORDERDARK_WITH_FRAME),
buildSticker('SLIME', SLIME_WITH_FRAME),
buildSticker('SPHAGETTI_BATH', SPHAGETTI_BATH_WITH_FRAME),
buildSticker('THUG_LIFE', THUG_LIFE_WITH_BORDER),
buildSticker('WHUUT', WHUUT_WITH_FRAME),
];
export const PAID_GLOBAL_STICKERS = [
buildSticker('TIP_HAND_FLIP', TIP_HAND_FLIP, 1),
buildSticker('TIP_HAND_FLIP_COIN', TIP_HAND_FLIP_COIN, 1),
buildSticker('TIP_HAND_FLIP_LBC', TIP_HAND_FLIP_LBC, 1),
buildSticker('COMET_TIP', COMET_TIP, 25),
buildSticker('LBC_COMET_TIP', LBC_COMET_TIP, 25),
buildSticker('SMALL_TIP', SMALL_TIP, 25),
buildSticker('SILVER_ODYSEE_COIN', SILVER_ODYSEE_COIN, 25),
buildSticker('SMALL_LBC_TIP', SMALL_LBC_TIP, 25),
buildSticker('BITE_TIP', BITE_TIP, 50),
buildSticker('BITE_TIP_CLOSEUP', BITE_TIP_CLOSEUP, 50),
buildSticker('BITE_LBC_CLOSEUP', BITE_LBC_CLOSEUP, 50),
buildSticker('MEDIUM_TIP', MEDIUM_TIP, 50),
buildSticker('MEDIUM_LBC_TIP', MEDIUM_LBC_TIP, 50),
buildSticker('LARGE_TIP', LARGE_TIP, 100),
buildSticker('LARGE_LBC_TIP', LARGE_LBC_TIP, 100),
buildSticker('BIG_TIP', BIG_TIP, 150),
buildSticker('BIG_LBC_TIP', BIG_LBC_TIP, 150),
buildSticker('FORTUNE_CHEST', FORTUNE_CHEST, 200),
buildSticker('FORTUNE_CHEST_LBC', FORTUNE_CHEST_LBC, 200),
];

View file

@ -553,6 +553,7 @@ export function doCommentReact(commentId: string, type: string) {
* @param parent_id - What is this? * @param parent_id - What is this?
* @param uri * @param uri
* @param livestream * @param livestream
* @param sticker
* @param {string} [txid] Optional transaction id * @param {string} [txid] Optional transaction id
* @param {string} [payment_intent_id] Optional transaction id * @param {string} [payment_intent_id] Optional transaction id
* @param {string} [environment] Optional environment for Stripe (test|live) * @param {string} [environment] Optional environment for Stripe (test|live)
@ -566,7 +567,8 @@ export function doCommentCreate(
livestream?: boolean = false, livestream?: boolean = false,
txid?: string, txid?: string,
payment_intent_id?: string, payment_intent_id?: string,
environment?: string environment?: string,
sticker: boolean
) { ) {
return async (dispatch: Dispatch, getState: GetState) => { return async (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
@ -579,9 +581,7 @@ export function doCommentCreate(
return; return;
} }
dispatch({ dispatch({ type: ACTIONS.COMMENT_CREATE_STARTED });
type: ACTIONS.COMMENT_CREATE_STARTED,
});
let signatureData; let signatureData;
if (activeChannelClaim) { if (activeChannelClaim) {
@ -594,12 +594,8 @@ export function doCommentCreate(
} }
// send a notification // send a notification
if (parent_id) { const notification = parent_id && makeSelectNotificationForCommentId(parent_id)(state);
const notification = makeSelectNotificationForCommentId(parent_id)(state); if (notification && !notification.is_seen) dispatch(doSeeNotifications([notification.id]));
if (notification && !notification.is_seen) {
dispatch(doSeeNotifications([notification.id]));
}
}
if (!signatureData) { if (!signatureData) {
return dispatch(doToast({ isError: true, message: __('Unable to verify your channel. Please try again.') })); return dispatch(doToast({ isError: true, message: __('Unable to verify your channel. Please try again.') }));
@ -615,6 +611,7 @@ export function doCommentCreate(
parent_id: parent_id, parent_id: parent_id,
signature: signatureData.signature, signature: signatureData.signature,
signing_ts: signatureData.signing_ts, signing_ts: signatureData.signing_ts,
sticker: sticker,
...(txid ? { support_tx_id: txid } : {}), // add transaction id if it exists ...(txid ? { support_tx_id: txid } : {}), // add transaction id if it exists
...(payment_intent_id ? { payment_intent_id } : {}), // add payment_intent_id if it exists ...(payment_intent_id ? { payment_intent_id } : {}), // add payment_intent_id if it exists
...(environment ? { environment } : {}), // add environment for stripe if it exists ...(environment ? { environment } : {}), // add environment for stripe if it exists

View file

@ -1,5 +1,6 @@
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import Lbry from 'lbry'; import Lbry from 'lbry';
import { Lbryio } from 'lbryinc';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { import {
selectBalance, selectBalance,
@ -12,7 +13,6 @@ import {
import { creditsToString } from 'util/format-credits'; import { creditsToString } from 'util/format-credits';
import { selectMyClaimsRaw, selectClaimsById } from 'redux/selectors/claims'; import { selectMyClaimsRaw, selectClaimsById } from 'redux/selectors/claims';
import { doFetchChannelListMine, doFetchClaimListMine, doClaimSearch } from 'redux/actions/claims'; import { doFetchChannelListMine, doFetchClaimListMine, doClaimSearch } from 'redux/actions/claims';
const FIFTEEN_SECONDS = 15000; const FIFTEEN_SECONDS = 15000;
let walletBalancePromise = null; let walletBalancePromise = null;
@ -705,3 +705,46 @@ export const doCheckPendingTxs = () => (dispatch, getState) => {
checkTxList(); checkTxList();
}, 30000); }, 30000);
}; };
export const doSendCashTip = (tipParams, anonymous, userParams, claimId, stripeEnvironment, successCallback) => (
dispatch
) => {
Lbryio.call(
'customer',
'tip',
{
// round to fix issues with floating point numbers
amount: Math.round(100 * tipParams.tipAmount), // convert from dollars to cents
creator_channel_name: tipParams.tipChannelName, // creator_channel_name
creator_channel_claim_id: tipParams.channelClaimId,
tipper_channel_name: anonymous ? '' : userParams.activeChannelName,
tipper_channel_claim_id: anonymous ? '' : userParams.activeChannelId,
currency: 'USD',
anonymous: anonymous,
source_claim_id: claimId,
environment: stripeEnvironment,
},
'post'
)
.then((customerTipResponse) => {
dispatch(
doToast({
message: __("You sent $%tipAmount% as a tip to %tipChannelName%, I'm sure they appreciate it!", {
tipAmount: tipParams.tipAmount,
tipChannelName: tipParams.tipChannelName,
}),
})
);
if (successCallback) successCallback(customerTipResponse);
})
.catch((error) => {
// show error message from Stripe if one exists (being passed from backend by Beamer's API currently)
dispatch(
doToast({
message: error.message || __('Sorry, there was an error in processing your payment!'),
isError: true,
})
);
});
};

View file

@ -3,7 +3,11 @@
$thumbnailWidth: 1.5rem; $thumbnailWidth: 1.5rem;
$thumbnailWidthSmall: 1rem; $thumbnailWidthSmall: 1rem;
.comment__create { .content_comment {
position: relative;
}
.commentCreate {
font-size: var(--font-small); font-size: var(--font-small);
position: relative; position: relative;
@ -19,16 +23,12 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment__create--reply { .commentCreate--reply {
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
position: relative; position: relative;
} }
.content_comment { .commentCreate--nestedReply {
position: relative;
}
.comment__create--nested-reply {
margin-top: var(--spacing-s); margin-top: var(--spacing-s);
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px); margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
@ -37,11 +37,11 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment__create--bottom { .commentCreate--bottom {
padding-bottom: 0; padding-bottom: 0;
} }
.comment-new__label-wrapper { .commentCreate__labelWrapper {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
@ -49,6 +49,11 @@ $thumbnailWidthSmall: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%; width: 100%;
.commentCreate__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
@media (min-width: $breakpoint-small) { @media (min-width: $breakpoint-small) {
fieldset-section { fieldset-section {
max-width: 10rem; max-width: 10rem;
@ -56,27 +61,50 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment-new__label { .commentCreate__supportCommentPreview {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
.comment__sc-preview {
display: flex; display: flex;
align-items: center; align-items: center;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: var(--spacing-s); padding: var(--spacing-s);
margin: var(--spacing-s) 0; margin: var(--spacing-s) 0;
.commentCreate__supportCommentPreviewAmount {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
} }
.comment__sc-preview-amount { .commentCreate__minAmountNotice {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
.comment--min-amount-notice {
.icon { .icon {
margin-bottom: -3px; // TODO fix few instances of these (find "-2px") margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
} }
} }
.commentCreate__stickerPreview {
@extend .commentCreate;
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-s);
margin: var(--spacing-s) 0;
overflow: hidden;
width: 100%;
height: 10rem;
.commentCreate__stickerPreviewInfo {
display: flex;
align-items: flex-start;
}
.commentCreate__stickerPreviewImage {
width: 100%;
height: 100%;
margin-left: var(--spacing-m);
}
.filePrice {
height: 1.5rem;
width: 10rem;
}
}

View file

@ -418,10 +418,6 @@ $thumbnailWidthSmall: 1rem;
margin-right: var(--spacing-s); margin-right: var(--spacing-s);
} }
.comment__tip-input {
margin: var(--spacing-s) 0;
}
.comment--blocked { .comment--blocked {
opacity: 0.5; opacity: 0.5;
} }
@ -482,3 +478,14 @@ $thumbnailWidthSmall: 1rem;
} }
} }
} }
.sticker__comment {
margin-left: var(--spacing-m);
height: 6rem;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
}
}

View file

@ -0,0 +1,144 @@
@import '../init/vars.scss';
.filePrice {
position: relative;
display: flex;
align-items: center;
color: var(--color-purchased-text);
.credit-amount,
.icon--Key {
position: relative;
margin-left: var(--spacing-m);
white-space: nowrap;
color: var(--color-purchased-text);
}
&::before {
position: absolute;
content: '';
left: 0;
width: 250%;
height: 160%;
transform: skew(15deg);
border-radius: var(--border-radius);
background-color: var(--color-purchased-alt);
border: 2px solid var(--color-purchased);
}
}
.filePrice--filepage {
font-size: var(--font-body);
top: calc(var(--spacing-xxs) * -1);
margin-left: var(--spacing-m);
.credit-amount {
margin: 0 var(--spacing-m);
margin-bottom: -0.5rem;
}
&::before {
height: 250%;
left: calc(var(--spacing-m) * -1);
border-radius: 0;
border-bottom-left-radius: var(--border-radius);
border-width: 5px;
border-top-width: 0;
}
@media (max-width: $breakpoint-small) {
padding: var(--spacing-s);
&::before {
height: 140%;
}
}
}
.filePrice--modal {
border: 5px solid var(--color-purchased);
border-radius: var(--border-radius);
font-size: var(--font-body);
height: 4rem;
background-color: var(--color-purchased-alt);
transform: skew(15deg);
.icon,
.credit-amount {
transform: skew(-15deg);
}
.credit-amount {
margin: 0 var(--spacing-m);
margin-left: var(--spacing-l);
font-weight: var(--font-bold);
font-size: var(--font-large);
}
&::before {
content: none;
}
}
.filePrice__key {
@extend .filePrice;
color: var(--color-gray-5);
.icon {
fill: white;
}
&::before {
background-color: var(--color-purchased);
height: 180%;
}
}
.filePrice__key--filepage {
@extend .filePrice--filepage;
top: 0;
&::before {
height: 300%;
}
.icon {
margin: 0 var(--spacing-m);
}
@media (max-width: $breakpoint-small) {
&::before {
top: calc(-1 * var(--spacing-s));
height: 110%;
}
.icon {
top: calc(-1 * var(--spacing-s));
margin: 0 var(--spacing-xs);
}
}
}
.filePrice__key--modal {
@extend .filePrice--modal;
top: var(--spacing-m);
.icon {
height: 100%;
width: auto;
left: calc(var(--spacing-xl) * 1.5);
animation: moveKey 2.5s 1 ease-out;
overflow: visible;
stroke: var(--color-black);
g {
animation: turnKey 2.5s 1 ease-out;
}
}
&::before {
content: '';
transform: skew(15deg);
animation: expand 2.5s 1 ease-out;
}
}

View file

@ -109,6 +109,8 @@ $recent-msg-button__height: 2rem;
} }
.livestream-comment--superchat { .livestream-comment--superchat {
background-color: var(--color-card-background-highlighted);
+ .livestream-comment--superchat { + .livestream-comment--superchat {
margin-bottom: var(--spacing-xxs); margin-bottom: var(--spacing-xxs);
} }
@ -139,11 +141,15 @@ $recent-msg-button__height: 2rem;
.livestream-comment__body { .livestream-comment__body {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
flex: 2;
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
overflow: hidden; overflow: hidden;
.livestream-comment__info--sticker {
display: flex;
margin: var(--spacing-xxs) 0;
}
.channel-thumbnail { .channel-thumbnail {
@include handleChannelGif(2rem); @include handleChannelGif(2rem);
margin-top: var(--spacing-xxs); margin-top: var(--spacing-xxs);
@ -338,6 +344,28 @@ $recent-msg-button__height: 2rem;
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
} }
.livestream-superchat__info--sticker {
display: flex;
align-items: flex-start;
flex-direction: row;
width: 8rem;
height: 3rem;
.livestream-superchat__info--image {
padding-left: var(--spacing-m);
width: 100%;
height: 100%;
}
.button {
margin-top: calc(var(--spacing-xxs) / 2);
}
}
.livestream-superchat__info--not-sticker {
flex-direction: row;
}
.livestream-superchat__banner { .livestream-superchat__banner {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;

View file

@ -15,7 +15,7 @@ $contentMaxWidth: 60rem;
} }
} }
.comment__create, .commentCreate,
.comment__content { .comment__content {
margin: var(--spacing-m); margin: var(--spacing-m);
margin-bottom: 0; margin-bottom: 0;

View file

@ -7,155 +7,6 @@
} }
} }
.file-price {
position: relative;
display: flex;
align-items: center;
color: var(--color-purchased-text);
.credit-amount,
.icon--Key {
position: relative;
margin-left: var(--spacing-m);
white-space: nowrap;
color: var(--color-purchased-text);
}
&::before {
position: absolute;
content: '';
left: 0;
width: 250%;
height: 160%;
transform: skew(15deg);
border-radius: var(--border-radius);
background-color: var(--color-purchased-alt);
border: 2px solid var(--color-purchased);
}
}
.file-price__key {
@extend .file-price;
color: var(--color-gray-5);
.icon {
fill: white;
}
&::before {
background-color: var(--color-purchased);
height: 180%;
}
}
.file-price--filepage {
font-size: var(--font-body);
top: calc(var(--spacing-xxs) * -1);
margin-left: var(--spacing-m);
.credit-amount {
margin: 0 var(--spacing-m);
margin-bottom: -0.5rem;
}
&::before {
height: 250%;
left: calc(var(--spacing-m) * -1);
border-radius: 0;
border-bottom-left-radius: var(--border-radius);
border-width: 5px;
border-top-width: 0;
}
@media (max-width: $breakpoint-small) {
padding: var(--spacing-s);
&::before {
height: 140%;
}
}
}
.file-price__key--filepage {
@extend .file-price--filepage;
top: 0;
&::before {
height: 300%;
}
.icon {
margin: 0 var(--spacing-m);
}
@media (max-width: $breakpoint-small) {
&::before {
top: calc(-1 * var(--spacing-s));
height: 110%;
}
.icon {
top: calc(-1 * var(--spacing-s));
margin: 0 var(--spacing-xs);
}
}
}
.file-price--modal {
border: 5px solid var(--color-purchased);
.credit-amount {
margin: 0 var(--spacing-m);
margin-left: var(--spacing-l);
font-weight: var(--font-bold);
}
}
.file-price--modal {
font-size: var(--font-body);
height: 4rem;
background-color: var(--color-purchased-alt);
border-radius: var(--border-radius);
transform: skew(15deg);
.icon,
.credit-amount {
transform: skew(-15deg);
}
.credit-amount {
font-size: var(--font-large);
}
&::before {
content: none;
}
}
.file-price__key--modal {
@extend .file-price--modal;
top: var(--spacing-m);
.icon {
height: 100%;
width: auto;
left: calc(var(--spacing-xl) * 1.5);
animation: moveKey 2.5s 1 ease-out;
overflow: visible;
stroke: var(--color-black);
g {
animation: turnKey 2.5s 1 ease-out;
}
}
&::before {
content: '';
transform: skew(15deg);
animation: expand 2.5s 1 ease-out;
}
}
.purchase-stuff { .purchase-stuff {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -0,0 +1,82 @@
@import '../init/vars';
.stickerSelector {
animation: menu-animate-in var(--animation-duration) var(--animation-style);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-m);
.stickerSelector__header {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-s);
margin-bottom: 0;
align-items: center;
padding: var(--spacing-xxs);
.stickerSelector__headerTitle {
padding: 0;
}
}
.navigation__wrapper {
height: unset;
border-left: 1px solid var(--color-border);
.navigation-links {
li {
.button {
padding: unset;
.button__content {
justify-content: unset;
flex-direction: unset;
width: unset;
.button__label {
font-size: var(--font-small);
margin: 0 var(--spacing-s);
}
}
}
}
}
}
}
.stickerSelector__list {
display: flex;
.stickerSelector__listBody {
display: flex;
flex-wrap: wrap;
overflow-y: scroll;
overflow-x: hidden;
max-height: 25vh;
padding: var(--spacing-s);
.button--file-action {
width: 5rem;
height: 5.3rem;
overflow: hidden;
margin: unset;
padding: var(--spacing-s);
.button__content {
display: flex;
flex-direction: column;
align-items: center;
.super-chat--light {
position: absolute;
display: inline;
bottom: 0;
}
}
@media (max-width: $breakpoint-xsmall) {
width: 4rem;
height: 4.3rem;
}
}
}
}

View file

@ -0,0 +1,3 @@
.walletTipSelector__input {
margin: var(--spacing-s) 0;
}

View file

@ -1,6 +1,10 @@
// @flow // @flow
import * as REACTION_TYPES from 'constants/reactions';
import { SORT_COMMENTS_NEW, SORT_COMMENTS_BEST, SORT_COMMENTS_CONTROVERSIAL } from 'constants/comment'; import { SORT_COMMENTS_NEW, SORT_COMMENTS_BEST, SORT_COMMENTS_CONTROVERSIAL } from 'constants/comment';
import { FREE_GLOBAL_STICKERS, PAID_GLOBAL_STICKERS } from 'constants/stickers';
import * as REACTION_TYPES from 'constants/reactions';
const ALL_VALID_STICKERS = [...FREE_GLOBAL_STICKERS, ...PAID_GLOBAL_STICKERS];
const stickerRegex = /(<stkr>:[A-Z0-9_]+:<stkr>)/;
// Mostly taken from Reddit's sorting functions // Mostly taken from Reddit's sorting functions
// https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx // https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx
@ -88,3 +92,19 @@ export function sortComments(sortProps: SortProps): Array<Comment> {
return 0; return 0;
}); });
} }
export const buildValidSticker = (sticker: string) => `<stkr>${sticker}<stkr>`;
export function parseSticker(comment: string) {
const matchSticker = comment.match(stickerRegex);
const stickerValue = matchSticker && matchSticker[0];
const commentIsSticker = stickerValue && stickerValue.length === comment.length;
return (
commentIsSticker &&
ALL_VALID_STICKERS.find((sticker) => {
// $FlowFixMe
return sticker.name === stickerValue.replaceAll('<stkr>', '');
})
);
}