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

View file

@ -6,26 +6,26 @@ import {
selectFetchingMyChannels,
makeSelectTagInClaimOrChannelForUri,
} 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 { doSendTip, doSendCashTip } from 'redux/actions/wallet';
import { doToast } from 'redux/actions/notifications';
import { selectActiveChannelClaim } from 'redux/selectors/app';
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) => ({
claim: makeSelectClaimForUri(props.uri)(state),
channels: selectMyChannelClaims(state),
isFetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state),
channels: selectMyChannelClaims(state),
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
isFetchingChannels: selectFetchingMyChannels(state),
settingsByChannelId: selectSettingsByChannelId(state),
supportDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_SUPPORT_TAG)(state),
});
const perform = (dispatch, ownProps) => ({
createComment: (comment, claimId, parentId, txid, payment_intent_id, environment) =>
createComment: (comment, claimId, parentId, txid, payment_intent_id, environment, sticker) =>
dispatch(
doCommentCreate(
comment,
@ -35,13 +35,16 @@ const perform = (dispatch, ownProps) => ({
ownProps.livestream,
txid,
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)),
doToast: (options) => dispatch(doToast(options)),
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);

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
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 { FormField, Form } from 'component/common/form';
import { getChannelIdFromClaim } from 'util/claim';
import { Lbryio } from 'lbryinc';
import { SIMPLE_SITE } from 'config';
import { useHistory } from 'react-router';
import * as ICONS from 'constants/icons';
@ -16,99 +16,113 @@ import classnames from 'classnames';
import CreditAmount from 'component/common/credit-amount';
import EmoteSelector from './emote-selector';
import Empty from 'component/common/empty';
import FilePrice from 'component/filePrice';
import I18nMessage from 'component/i18nMessage';
import Icon from 'component/common/icon';
import OptimizedImage from 'component/optimizedImage';
import React from 'react';
import SelectChannel from 'component/selectChannel';
import StickerSelector from './sticker-selector';
import type { ElementRef } from 'react';
import UriIndicator from 'component/uriIndicator';
import usePersistedState from 'effects/use-persisted-state';
import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
const stripeEnvironment = getStripeEnvironment();
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
const MENTION_DEBOUNCE_MS = 100;
type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string };
type UserParams = { activeChannelName: ?string, activeChannelId: ?string };
type Props = {
uri: string,
claim: StreamClaim,
channels: ?Array<ChannelClaim>,
isNested: boolean,
isFetchingChannels: boolean,
parentId: string,
isReply: boolean,
activeChannel: string,
activeChannelClaim: ?ChannelClaim,
bottom: boolean,
livestream?: boolean,
embed?: boolean,
channels: ?Array<ChannelClaim>,
claim: StreamClaim,
claimIsMine: boolean,
supportDisabled: boolean,
embed?: boolean,
isFetchingChannels: boolean,
isNested: boolean,
isReply: boolean,
livestream?: boolean,
parentId: string,
settingsByChannelId: { [channelId: string]: PerChannelSettings },
shouldFetchComment: boolean,
doToast: ({ message: string }) => void,
createComment: (string, string, string, ?string, ?string, ?string) => Promise<any>,
onDoneReplying?: () => void,
onCancelReplying?: () => void,
toast: (string) => void,
sendTip: ({}, (any) => void, (any) => void) => void,
supportDisabled: boolean,
uri: string,
createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
doFetchCreatorSettings: (channelId: string) => Promise<any>,
setQuickReply: (any) => void,
doToast: ({ message: string }) => void,
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) {
const {
uri,
claim,
channels,
isNested,
isFetchingChannels,
isReply,
parentId,
activeChannelClaim,
bottom,
livestream,
embed,
channels,
claim,
claimIsMine,
embed,
isFetchingChannels,
isNested,
isReply,
livestream,
parentId,
settingsByChannelId,
supportDisabled,
shouldFetchComment,
doToast,
supportDisabled,
uri,
createComment,
onDoneReplying,
onCancelReplying,
sendTip,
doFetchCreatorSettings,
setQuickReply,
doToast,
fetchComment,
onCancelReplying,
onDoneReplying,
sendCashTip,
sendTip,
setQuickReply,
} = props;
const formFieldRef: ElementRef<any> = React.useRef();
const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input;
const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart;
const buttonRef: ElementRef<any> = React.useRef();
const {
push,
location: { pathname },
} = useHistory();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isSubmitting, setSubmitting] = React.useState(false);
const [commentFailure, setCommentFailure] = React.useState(false);
const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined });
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 [convertedAmount, setConvertedAmount] = React.useState();
const [commentValue, setCommentValue] = React.useState('');
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const [stickerSelector, setStickerSelector] = React.useState();
const [activeTab, setActiveTab] = React.useState('');
const [tipError, setTipError] = React.useState();
const [deletedComment, setDeletedComment] = React.useState(false);
const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
const [showEmotes, setShowEmotes] = React.useState(false);
const [disableReviewButton, setDisableReviewButton] = React.useState();
const selectedMentionIndex =
commentValue.indexOf('@', selectionIndex) === selectionIndex
@ -128,8 +142,7 @@ export function CommentCreate(props: Props) {
: '';
const claimId = claim && claim.claim_id;
const signingChannel = (claim && claim.signing_channel) || claim;
const channelUri = signingChannel && signingChannel.permanent_url;
const channelUri = claim && (claim.signing_channel ? claim.signing_channel.permanent_url : claim.permanent_url);
const hasChannels = channels && channels.length;
const charCount = commentValue ? commentValue.length : 0;
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend;
@ -143,31 +156,23 @@ export function CommentCreate(props: Props) {
const minAmountRef = React.useRef(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
// **************************************************************************
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) {
let commentValue;
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() {
if (!activeChannelClaim) {
return;
}
if (!activeChannelClaim) return;
if (!channelId) {
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.'),
isError: true,
});
setIsReviewingSupportComment(false);
setReviewingSupportComment(false);
return;
}
@ -248,33 +243,17 @@ export function CommentCreate(props: Props) {
}
function doSubmitTip() {
if (!activeChannelClaim) {
return;
}
const params = {
amount: tipAmount,
claim_id: claimId,
channel_id: activeChannelClaim.claim_id,
};
if (!activeChannelClaim) return;
const params = { amount: tipAmount, claim_id: claimId, channel_id: activeChannelClaim.claim_id };
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
// 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;
const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id;
const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name;
// otherwise it's on the channel page
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
}
setIsSubmitting(true);
setSubmitting(true);
if (activeTab === TAB_LBC) {
// call sendTip and then run the callback from the response
@ -302,58 +281,23 @@ export function CommentCreate(props: Props) {
},
() => {
// reset the frontend so people can send a new comment
setIsSubmitting(false);
setSubmitting(false);
}
);
} else {
const sourceClaimId = claim.claim_id;
const roundedAmount = Math.round(tipAmount * 100) / 100;
const tipParams: TipParams = { tipAmount: Math.round(tipAmount * 100) / 100, tipChannelName, channelClaimId };
const userParams: UserParams = { activeChannelName, activeChannelId };
Lbryio.call(
'customer',
'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;
sendCashTip(tipParams, userParams, claim.claim_id, stripeEnvironment, (customerTipResponse) => {
const { payment_intent_id } = customerTipResponse;
handleCreateComment(null, paymentIntendId, stripeEnvironment);
handleCreateComment(null, payment_intent_id, stripeEnvironment);
setCommentValue('');
setIsReviewingSupportComment(false);
setReviewingSupportComment(false);
setIsSupportComment(false);
setCommentFailure(false);
setIsSubmitting(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,
});
setSubmitting(false);
});
}
}
@ -366,16 +310,17 @@ export function CommentCreate(props: Props) {
*/
function handleCreateComment(txid, payment_intent_id, environment) {
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) => {
setIsSubmitting(false);
setSubmitting(false);
if (setQuickReply) setQuickReply(res);
if (res && res.signature) {
setCommentValue('');
setIsReviewingSupportComment(false);
setReviewingSupportComment(false);
setIsSupportComment(false);
setCommentFailure(false);
@ -385,7 +330,7 @@ export function CommentCreate(props: Props) {
}
})
.catch(() => {
setIsSubmitting(false);
setSubmitting(false);
setCommentFailure(true);
if (channelId) {
@ -432,6 +377,10 @@ export function CommentCreate(props: Props) {
// Render
// **************************************************************************
const getActionButton = (title: string, icon: string, handleClick: () => void) => (
<Button title={title} button="alt" icon={icon} onClick={handleClick} />
);
if (channelSettings && !channelSettings.comments_enabled) {
return <Empty padded text={__('This channel has disabled comments on their page.')} />;
}
@ -456,50 +405,7 @@ export function CommentCreate(props: Props) {
>
<FormField type="textarea" name={'comment_signup_prompt'} placeholder={__('Say something about this...')} />
<div className="section__actions--no-margin">
<Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth={IS_WEB} />
</div>
</div>
);
}
if (isReviewingSupportComment && activeChannelClaim) {
return (
<div className="comment__create">
<div className="comment__sc-preview">
<CreditAmount
className="comment__sc-preview-amount"
isFiat={activeTab === TAB_FIAT}
amount={tipAmount}
size={activeTab === TAB_LBC ? 18 : 2}
/>
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<div>
<UriIndicator uri={activeChannelClaim.name} link />
<div>{commentValue}</div>
</div>
</div>
<div className="section__actions--no-margin">
<Button
autoFocus
button="primary"
disabled={disabled || !minAmountMet}
label={
isSubmitting
? __('Sending...')
: commentFailure && tipAmount === successTip.tipAmount
? __('Re-submit')
: __('Send')
}
onClick={handleSupportComment}
/>
<Button
disabled={isSubmitting}
button="link"
label={__('Cancel')}
onClick={() => setIsReviewingSupportComment(false)}
/>
{MinAmountNotice}
<Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth />
</div>
</div>
);
@ -507,12 +413,45 @@ export function CommentCreate(props: Props) {
return (
<Form
className={classnames('comment__create', {
'comment__create--reply': isReply,
'comment__create--nested-reply': isNested,
'comment__create--bottom': bottom,
className={classnames('commentCreate', {
'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
amount={tipAmount}
className="commentCreate__supportCommentPreviewAmount"
isFiat={activeTab === TAB_FIAT}
size={activeTab === TAB_LBC ? 18 : 2}
/>
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<div className="commentCreate__supportCommentBody">
<UriIndicator uri={activeChannelClaim.canonical_url} link />
<div>{commentValue}</div>
</div>
</div>
) : (
<>
{showEmotes && (
<EmoteSelector
commentValue={commentValue}
@ -531,6 +470,7 @@ export function CommentCreate(props: Props) {
customSelectAction={handleSelectMention}
/>
)}
<FormField
disabled={isFetchingChannels}
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
@ -538,9 +478,9 @@ export function CommentCreate(props: Props) {
ref={formFieldRef}
className={isReply ? 'content_reply' : 'content_comment'}
label={
<span className="comment-new__label-wrapper">
<span className="commentCreate__labelWrapper">
{!livestream && (
<div className="comment-new__label">{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}</div>
<div className="commentCreate__label">{isReply ? __('Replying as ') : __('Comment as ')}</div>
)}
<SelectChannel tiny />
</span>
@ -550,8 +490,8 @@ export function CommentCreate(props: Props) {
}
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
openEmoteMenu={() => setShowEmotes(!showEmotes)}
onFocus={onTextareaFocus}
onBlur={onTextareaBlur}
onFocus={() => window.addEventListener('keydown', altEnterListener)}
onBlur={() => window.removeEventListener('keydown', altEnterListener)}
placeholder={__('Say something about this...')}
value={commentValue}
charCount={charCount}
@ -559,43 +499,82 @@ export function CommentCreate(props: Props) {
autoFocus={isReply}
textAreaMaxLength={livestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
/>
{isSupportComment && (
</>
)}
{(isSupportComment || (isReviewingStickerComment && selectedSticker && selectedSticker.price)) && (
<WalletTipAmountSelector
onTipErrorChange={setTipError}
shouldDisableReviewButton={setShouldDisableReviewButton}
claim={claim}
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">
{isSupportComment ? (
<>
{/* Submit Button */}
{isReviewingSupportComment ? (
<Button
disabled={disabled || tipError || shouldDisableReviewButton || !minAmountMet}
autoFocus
button="primary"
disabled={disabled || !minAmountMet}
label={
isSubmitting
? __('Sending...')
: commentFailure && tipAmount === successTip.tipAmount
? __('Re-submit')
: __('Send')
}
onClick={handleSupportComment}
/>
) : isReviewingStickerComment && selectedSticker ? (
<Button
button="primary"
label={__('Send')}
disabled={
(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={() => setIsReviewingSupportComment(true)}
requiresAuth={IS_WEB}
onClick={() => setReviewingSupportComment(true)}
requiresAuth
/>
<Button
disabled={isSubmitting}
button="link"
label={__('Cancel')}
onClick={() => setIsSupportComment(false)}
/>
</>
) : (
<>
{(!minTip || claimIsMine) && (
(!minTip || claimIsMine) && (
<Button
ref={buttonRef}
button="primary"
disabled={disabled}
disabled={disabled || stickerSelector}
type="submit"
label={
isReply
@ -606,53 +585,96 @@ export function CommentCreate(props: Props) {
? __('Commenting...')
: __('Comment --[button to submit something]--')
}
requiresAuth={IS_WEB}
requiresAuth
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
/>
)
)}
{!supportDisabled && !claimIsMine && (
{/** Stickers/Support Buttons **/}
{!supportDisabled && !stickerSelector && (
<>
{isReviewingStickerComment ? (
<Button
disabled={disabled}
button="alt"
className="thatButton"
icon={ICONS.LBC}
label={__('Different Sticker')}
onClick={() => {
setReviewingStickerComment(false);
setIsSupportComment(false);
setStickerSelector(true);
}}
/>
) : (
getActionButton(__('Stickers'), ICONS.TAG, () => {
setIsSupportComment(false);
setStickerSelector(true);
})
)}
{!claimIsMine &&
getActionButton(__('LBC'), ICONS.LBC, () => {
setIsSupportComment(true);
setActiveTab(TAB_LBC);
}}
/>
{/* @if TARGET='web' */}
{stripeEnvironment && (
<Button
disabled={disabled}
button="alt"
className="thisButton"
icon={ICONS.FINANCE}
onClick={() => {
})}
{!claimIsMine &&
stripeEnvironment &&
getActionButton(__('Cash'), ICONS.FINANCE, () => {
setIsSupportComment(true);
setActiveTab(TAB_FIAT);
}}
/>
)}
{/* @endif */}
})}
</>
)}
{isReply && !minTip && (
{/* Cancel Button */}
{(isSupportComment ||
isReviewingSupportComment ||
stickerSelector ||
isReviewingStickerComment ||
(isReply && !minTip)) && (
<Button
disabled={isSupportComment && isSubmitting}
button="link"
label={__('Cancel')}
onClick={() => {
if (onCancelReplying) {
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>}
{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>
</Form>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -5,36 +5,26 @@ import {
makeSelectClaimIsMine,
selectFetchingMyChannels,
} from 'redux/selectors/claims';
import { selectBalance, selectIsSendingSupport } from 'redux/selectors/wallet';
import { doSendTip } 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 { doHideModal } from 'redux/actions/app';
import { doSendTip, doSendCashTip } from 'redux/actions/wallet';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doToast } from 'redux/actions/notifications';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectBalance, selectIsSendingSupport } from 'redux/selectors/wallet';
import { withRouter } from 'react-router';
import * as SETTINGS from 'constants/settings';
import WalletSendTip from './view';
const select = (state, props) => ({
isPending: selectIsSendingSupport(state),
title: makeSelectTitleForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri, false)(state),
activeChannelClaim: selectActiveChannelClaim(state),
balance: selectBalance(state),
instantTipEnabled: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_ENABLED)(state),
instantTipMax: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_MAX)(state),
claim: makeSelectClaimForUri(props.uri, false)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
fetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(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) => ({
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));
export default withRouter(connect(select, { doHideModal, doSendTip, doSendCashTip })(WalletSendTip));

View file

@ -1,405 +1,176 @@
// @flow
import { Form } from 'component/common/form';
import { Lbryio } from 'lbryinc';
import { parseURI } from 'util/lbryURI';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import React from 'react';
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 classnames from 'classnames';
import ChannelSelector from 'component/channelSelector';
import classnames from 'classnames';
import I18nMessage from 'component/i18nMessage';
import LbcSymbol from 'component/common/lbc-symbol';
import { parseURI } from 'util/lbryURI';
import React from 'react';
import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { getStripeEnvironment } from 'util/stripe';
let 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 stripeEnvironment = getStripeEnvironment();
const TAB_BOOST = 'TabBoost';
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
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 = {
uri: string,
claimIsMine: boolean,
title: string,
claim: StreamClaim,
isPending: boolean,
isSupport: boolean,
sendSupport: (SupportParams, boolean) => void,
closeModal: () => void,
activeChannelClaim: ?ChannelClaim,
balance: number,
claim: StreamClaim,
claimIsMine: boolean,
fetchingChannels: boolean,
incognito: boolean,
instantTipEnabled: boolean,
instantTipMax: { amount: number, currency: string },
activeChannelClaim: ?ChannelClaim,
incognito: boolean,
doToast: ({ message: string }) => void,
isAuthenticated: boolean,
isPending: boolean,
isSupport: boolean,
title: string,
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) {
const {
uri,
title,
isPending,
claimIsMine,
activeChannelClaim,
balance,
claim = {},
instantTipEnabled,
instantTipMax,
sendSupport,
closeModal,
claimIsMine,
fetchingChannels,
incognito,
activeChannelClaim,
doToast,
isAuthenticated,
instantTipEnabled,
instantTipMax,
isPending,
title,
uri,
doHideModal,
doSendCashTip,
doSendTip,
} = props;
/** REACT 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);
/** STATE **/
// only allow certain creators to receive tips
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(); // dont persist because it needs to be calc'd per creator
// 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 [tipAmount, setTipAmount] = usePersistedState('comment-support:customTip', 1.0);
const [isOnConfirmationPage, setConfirmationPage] = React.useState(false);
const [tipError, setTipError] = React.useState();
// denote which tab to show on the frontend
const [activeTab, setActiveTab] = usePersistedState(TAB_BOOST);
const [disableSubmitButton, setDisableSubmitButton] = React.useState();
// handle default active tab
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);
}
}
}, []);
/** CONSTS **/
// 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;
// channel name used in url
const { channelName } = parseURI(uri);
let channelName;
try {
({ channelName } = parseURI(uri));
} catch (e) {}
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
// setup variables for backend 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;
const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id;
const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name;
// otherwise it's on the channel page
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
// icon to use or explainer text to show per tab
let explainerText = '',
confirmLabel = '';
switch (activeTab) {
case TAB_BOOST:
explainerText = __(
'This refundable boost will improve the discoverability of this %claimTypeText% while active. ',
{ claimTypeText }
);
confirmLabel = __('Boosting');
break;
case TAB_FIAT:
explainerText = __('Show this channel your appreciation by sending a donation in USD. ');
confirmLabel = __('Tipping Fiat (USD)');
break;
case TAB_LBC:
explainerText = __('Show this channel your appreciation by sending a donation of Credits. ');
confirmLabel = __('Tipping Credit');
break;
}
const sourceClaimId = claim.claim_id;
/** FUNCTIONS **/
// 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') {
switch (claim.value_type) {
case 'stream':
return __('Content');
} else if (claim.value_type === 'channel') {
case 'channel':
return __('Channel');
} else if (claim.value_type === 'repost') {
case 'repost':
return __('Repost');
} else if (claim.value_type === 'collection') {
case 'collection':
return __('List');
} else {
default:
return __('Claim');
}
}
const claimTypeText = getClaimTypeText();
// icon to use or explainer text to show per tab
let iconToUse;
let explainerText = '';
if (activeTab === TAB_BOOST) {
iconToUse = ICONS.LBC;
explainerText = __('This refundable boost will improve the discoverability of this %claimTypeText% while active.', {
claimTypeText,
});
} else if (activeTab === TAB_FIAT) {
iconToUse = ICONS.FINANCE;
explainerText = __('Show this channel your appreciation by sending a donation in USD.');
// if (!hasCardSaved) {
// explainerText += __('You must add a card to use this functionality.');
// }
} else if (activeTab === TAB_LBC) {
iconToUse = ICONS.LBC;
explainerText = __('Show this channel your appreciation by sending a donation of Credits.');
}
const isSupport = claimIsMine || activeTab === TAB_BOOST;
React.useEffect(() => {
// Regex for number up to 8 decimal places
let regexp;
let tipError;
if (tipAmount === 0) {
tipError = __('Amount must be a positive number');
} else if (!tipAmount || typeof tipAmount !== '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(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
function sendSupportOrConfirm(instantTipMaxAmount = null) {
// send a tip
if (!isConfirming && (!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)) {
setIsConfirming(true);
if (!isOnConfirmationPage && (!instantTipMaxAmount || !instantTipEnabled || tipAmount > instantTipMaxAmount)) {
setConfirmationPage(true);
} else {
// send a boost
const supportParams: SupportParams = { amount: tipAmount, claim_id: claimId };
// include channel name if donation not anonymous
if (activeChannelClaim && !incognito) {
supportParams.channel_id = activeChannelClaim.claim_id;
}
const supportParams: SupportParams = {
amount: tipAmount,
claim_id: claimId,
channel_id: activeChannelClaim && !incognito ? activeChannelClaim.claim_id : undefined,
};
// send tip/boost
sendSupport(supportParams, isSupport);
closeModal();
doSendTip(supportParams, isSupport);
doHideModal();
}
}
// when the form button is clicked
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);
});
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;
if (!isOnConfirmationPage) {
setConfirmationPage(true);
} else {
const tipParams: TipParams = { tipAmount, tipChannelName, channelClaimId };
const userParams: UserParams = { activeChannelName, activeChannelId };
// hit backend to send tip
Lbryio.call(
'customer',
'tip',
{
// 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();
doSendCashTip(tipParams, !activeChannelClaim || incognito, userParams, claimId, stripeEnvironment);
doHideModal();
}
// if it's a boost (?)
} else {
sendSupportOrConfirm();
}
}
}
const countDecimals = function (value) {
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 {
setCustomTipAmount(tipAmount);
}
// LBC tip input
} else {
// TODO: this is a bit buggy, needs a touchup
// 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);
}
}
function buildButtonText() {
// test if frontend will show up as isNan
@ -407,10 +178,7 @@ function WalletSendTip(props: Props) {
// testing for NaN ES5 style https://stackoverflow.com/a/35912757/3973137
// also sometimes it's returned as a string
// eslint-disable-next-line
if (tipAmount !== tipAmount || tipAmount === 'NaN') {
return true;
}
return false;
return tipAmount !== tipAmount || tipAmount === 'NaN';
}
function convertToTwoDecimals(number) {
@ -423,129 +191,65 @@ function WalletSendTip(props: Props) {
const displayAmount = !isNan(tipAmount) ? amountToShow : '';
// build button text based on tab
if (activeTab === TAB_BOOST) {
return claimIsMine
? __('Boost Your %claimTypeText%', { claimTypeText })
: __('Boost This %claimTypeText%', { claimTypeText });
} else if (activeTab === TAB_FIAT) {
switch (activeTab) {
case TAB_BOOST:
return titleText;
case TAB_FIAT:
return __('Send a $%displayAmount% Tip', { displayAmount });
} else if (activeTab === TAB_LBC) {
case TAB_LBC:
return __('Send a %displayAmount% Credit Tip', { displayAmount });
}
}
// dont allow user to click send button
function shouldDisableAmountSelector(amount) {
return (
(amount > balance && activeTab !== TAB_FIAT) || (activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
);
}
/** RENDER **/
// showed on confirm page above amount
function setConfirmLabel() {
if (activeTab === TAB_LBC) {
return __('Tipping Credit');
} else if (activeTab === TAB_FIAT) {
return __('Tipping Fiat (USD)');
} else if (activeTab === TAB_BOOST) {
return __('Boosting');
}
}
const getTabButton = (tabIcon: string, tabLabel: string, tabName: string) => (
<Button
key={tabName}
icon={tabIcon}
label={tabLabel}
button="alt"
onClick={() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) tipInputElement.focus();
if (!isOnConfirmationPage) setActiveTab(tabName);
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === tabName })}
/>
);
return (
<Form onSubmit={handleSubmit}>
{/* 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 */}
<Card
title={
<LbcSymbol
postfix={
claimIsMine
? __('Boost Your %claimTypeText%', { claimTypeText })
: __('Support This %claimTypeText%', { claimTypeText })
}
size={22}
/>
}
title={<LbcSymbol postfix={titleText} size={22} />}
subtitle={
<React.Fragment>
<>
{!claimIsMine && (
<div className="section">
{/* tip LBC tab button */}
<Button
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 })}
/>
{getTabButton(ICONS.LBC, __('Tip'), TAB_LBC)}
{/* tip fiat tab button */}
{/* @if TARGET='web' */}
{stripeEnvironment && (
<Button
key="tip-fiat"
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 })}
/>
{stripeEnvironment && getTabButton(ICONS.FINANCE, __('Tip'), TAB_FIAT)}
{/* support LBC tab button */}
{getTabButton(ICONS.TRENDING, __('Boost'), TAB_BOOST)}
</div>
)}
{/* short explainer under the button */}
<div className="section__subtitle">
{explainerText + ' '}
{explainerText}
{/* {activeTab === TAB_FIAT && !hasCardSaved && <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add A Card')} button="link" />} */}
{
<Button
label={__('Learn more')}
button="link"
href="https://odysee.com/@OdyseeHelp:b/Monetization-of-Content:3"
/>
}
<Button label={__('Learn more')} button="link" href="https://lbry.com/faq/tipping" />
</div>
</React.Fragment>
</>
}
actions={
// confirmation modal, allow user to confirm or cancel transaction
isConfirming ? (
isOnConfirmationPage ? (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
@ -555,10 +259,10 @@ function WalletSendTip(props: Props) {
<div className="confirm__value">
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
</div>
<div className="confirm__label">{setConfirmLabel()}</div>
<div className="confirm__label">{confirmLabel}</div>
<div className="confirm__value">
{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} />
)}
@ -567,98 +271,23 @@ function WalletSendTip(props: Props) {
</div>
<div className="section__actions">
<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>
</>
) : !((activeTab === TAB_LBC || activeTab === TAB_BOOST) && noBalance) ? (
) : !((activeTab === TAB_LBC || activeTab === TAB_BOOST) && balance === 0) ? (
<>
<div className="section">
<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 */}
<div className="section">
{DEFAULT_TIP_AMOUNTS.map((amount) => (
<Button
key={amount}
disabled={shouldDisableAmountSelector(amount)}
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': tipAmount === amount && !useCustomTip,
'button-toggle--disabled': amount > balance,
})}
label={amount}
icon={iconToUse}
onClick={() => {
setPresetTipAmount(amount);
setUseCustomTip(false);
}}
<WalletTipAmountSelector
setTipError={setTipError}
tipError={tipError}
claim={claim}
activeTab={activeTab === TAB_BOOST ? TAB_LBC : activeTab}
amount={tipAmount}
onChange={(amount) => setTipAmount(amount)}
setDisableSubmitButton={setDisableSubmitButton}
/>
))}
<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 */}
<div className="section__actions">
@ -667,35 +296,25 @@ function WalletSendTip(props: Props) {
icon={isSupport ? ICONS.TRENDING : ICONS.SUPPORT}
button="primary"
type="submit"
disabled={
fetchingChannels ||
isPending ||
tipError ||
!tipAmount ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
}
disabled={fetchingChannels || isPending || tipError || !tipAmount || disableSubmitButton}
label={buildButtonText()}
/>
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</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
<Card
title={
<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Supporting content requires %lbc%</I18nMessage>
<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>
{__('Supporting content requires %lbc%')}
</I18nMessage>
}
subtitle={
<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>
}
actions={
@ -712,7 +331,7 @@ function WalletSendTip(props: Props) {
label={__('Buy/Swap Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
<Button button="link" label={__('Nevermind')} onClick={closeModal} />
<Button button="link" label={__('Nevermind')} onClick={doHideModal} />
</div>
}
/>

View file

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

View file

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

View file

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

View file

@ -1,76 +1,116 @@
// @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 PAGES from 'constants/pages';
import React from 'react';
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 React from 'react';
import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
const stripeEnvironment = getStripeEnvironment();
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
type Props = {
balance: number,
amount: number,
onChange: (number) => void,
isAuthenticated: boolean,
claim: StreamClaim,
uri: string,
onTipErrorChange: (string) => void,
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) {
const { balance, amount, onChange, activeTab, claim, onTipErrorChange, shouldDisableReviewButton } = props;
const [useCustomTip, setUseCustomTip] = usePersistedState('comment-support:useCustomTip', false);
const [tipError, setTipError] = React.useState();
const {
activeTab,
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 [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
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
* @param [amount] LBC amount (optional)
* @returns {boolean}
*/
function shouldDisableAmountSelector(amount) {
function shouldDisableAmountSelector(amount: number) {
// if it's LBC but the balance isn't enough, or fiat conditions met
// $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;
// parse number as float and sets it in the parent component
function handleCustomPriceChange(amount: number) {
const tipAmountValue = parseFloat(amount);
onChange(tipAmountValue);
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) {
if (!stripeEnvironment) return;
Lbryio.call(
'customer',
'status',
@ -87,12 +127,11 @@ function WalletTipAmountSelector(props: Props) {
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}
}, [stripeEnvironment]);
}, [setHasSavedCard]);
//
React.useEffect(() => {
if (stripeEnvironment) {
if (!stripeEnvironment) return;
Lbryio.call(
'account',
'check',
@ -108,38 +147,32 @@ function WalletTipAmountSelector(props: Props) {
setCanReceiveFiatTip(true);
}
})
.catch(function (error) {
// console.log(error);
});
}
}, [stripeEnvironment]);
.catch(() => {});
}, [canReceiveFiatTip, channelClaimId, tipChannelName]);
React.useEffect(() => {
// setHasSavedCard(false);
// setCanReceiveFiatTip(true);
let regexp,
tipError = '';
let regexp;
if (amount === 0) {
tipError = __('Amount must be a positive number');
setTipError(__('Amount cannot be zero.'));
} else if (!amount || typeof amount !== 'number') {
tipError = __('Amount must be a number');
}
setTipError(__('Amount must be a number.'));
} else {
// if it's not fiat, aka it's boost or lbc tip
else if (activeTab !== TAB_FIAT) {
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');
setTipError(__('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');
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) {
tipError = __('Amount must be higher');
setTipError(__('Amount must be higher'));
} else {
setTipError(false);
}
// if tip fiat tab
} else {
@ -147,29 +180,24 @@ function WalletTipAmountSelector(props: Props) {
const validTipInput = regexp.test(String(amount));
if (!validTipInput) {
tipError = __('Amount must have no more than 2 decimal places');
setTipError(__('Amount must have no more than 2 decimal places'));
} else if (amount < 1) {
tipError = __('Amount must be at least one dollar');
setTipError(__('Amount must be at least one dollar'));
} else if (amount > 1000) {
tipError = __('Amount cannot be over 1000 dollars');
setTipError(__('Amount cannot be over 1000 dollars'));
} else {
setTipError(false);
}
}
setTipError(tipError);
onTipErrorChange(tipError);
}, [amount, balance, setTipError, activeTab]);
// parse number as float and sets it in the parent component
function handleCustomPriceChange(amount: number) {
const tipAmount = parseFloat(amount);
onChange(tipAmount);
}
}, [activeTab, amount, balance, setTipError]);
const getHelpMessage = (helpMessage: any) => <div className="help">{helpMessage}</div>;
return (
<>
<div className="section">
{DEFAULT_TIP_AMOUNTS.map((defaultAmount) => (
{tipAmountsToDisplay.map((defaultAmount) => (
<Button
key={defaultAmount}
disabled={shouldDisableAmountSelector(defaultAmount)}
@ -186,9 +214,10 @@ function WalletTipAmountSelector(props: Props) {
}}
/>
))}
<Button
button="alt"
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)}
disabled={shouldDisableFiatSelectors}
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': useCustomTip,
})}
@ -207,60 +236,26 @@ function WalletTipAmountSelector(props: Props) {
)}
</div>
{useCustomTip && activeTab === TAB_FIAT && !hasCardSaved && (
<>
<div className="help">
<span className="help--spendable">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card ')} button="link" /> To{' '}
{__('Tip Creators')}
</span>
</div>
</>
)}
{/* 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>
</>
{customTipAmount &&
fiatConversion &&
activeTab !== TAB_FIAT &&
getHelpMessage(
__(
`This support is priced in $USD. ${
convertedAmount
? __(`The current exchange rate for the submitted amount is: $${convertToTwoDecimals(convertedAmount)}`)
: ''
}`
)
)}
{/* custom number input form */}
{useCustomTip && (
<div className="comment__tip-input">
<div className="walletTipSelector__input">
<FormField
autoFocus
autoFocus={!isMobile}
name="tip-input"
disabled={shouldDisableAmountSelector()}
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>
// </>
}
disabled={!customTipAmount && shouldDisableAmountSelector(0)}
error={tipError}
min="0"
step="any"
@ -274,35 +269,17 @@ function WalletTipAmountSelector(props: Props) {
{/* lbc tab */}
{activeTab === TAB_LBC && <WalletSpendableBalanceHelp />}
{/* fiat button but no card saved */}
{!useCustomTip && activeTab === TAB_FIAT && !hasCardSaved && (
{activeTab === TAB_FIAT &&
(!hasCardSaved
? getHelpMessage(
<>
<div className="help">
<span className="help--spendable">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card ')} button="link" /> To{' '}
{__('Tip Creators')}
</span>
</div>
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" />
{__(' To Tip Creators')}
</>
)}
{/* 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>
</>
)}
)
: !canReceiveFiatTip
? getHelpMessage(__('Only creators that verify cash accounts can receive tips'))
: getHelpMessage(__('Send a tip directly from your attached card')))}
</>
);
}

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 uri
* @param livestream
* @param sticker
* @param {string} [txid] Optional transaction id
* @param {string} [payment_intent_id] Optional transaction id
* @param {string} [environment] Optional environment for Stripe (test|live)
@ -566,7 +567,8 @@ export function doCommentCreate(
livestream?: boolean = false,
txid?: string,
payment_intent_id?: string,
environment?: string
environment?: string,
sticker: boolean
) {
return async (dispatch: Dispatch, getState: GetState) => {
const state = getState();
@ -579,9 +581,7 @@ export function doCommentCreate(
return;
}
dispatch({
type: ACTIONS.COMMENT_CREATE_STARTED,
});
dispatch({ type: ACTIONS.COMMENT_CREATE_STARTED });
let signatureData;
if (activeChannelClaim) {
@ -594,12 +594,8 @@ export function doCommentCreate(
}
// send a notification
if (parent_id) {
const notification = makeSelectNotificationForCommentId(parent_id)(state);
if (notification && !notification.is_seen) {
dispatch(doSeeNotifications([notification.id]));
}
}
const notification = parent_id && makeSelectNotificationForCommentId(parent_id)(state);
if (notification && !notification.is_seen) dispatch(doSeeNotifications([notification.id]));
if (!signatureData) {
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,
signature: signatureData.signature,
signing_ts: signatureData.signing_ts,
sticker: sticker,
...(txid ? { support_tx_id: txid } : {}), // add transaction 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

View file

@ -1,5 +1,6 @@
import * as ACTIONS from 'constants/action_types';
import Lbry from 'lbry';
import { Lbryio } from 'lbryinc';
import { doToast } from 'redux/actions/notifications';
import {
selectBalance,
@ -12,7 +13,6 @@ import {
import { creditsToString } from 'util/format-credits';
import { selectMyClaimsRaw, selectClaimsById } from 'redux/selectors/claims';
import { doFetchChannelListMine, doFetchClaimListMine, doClaimSearch } from 'redux/actions/claims';
const FIFTEEN_SECONDS = 15000;
let walletBalancePromise = null;
@ -705,3 +705,46 @@ export const doCheckPendingTxs = () => (dispatch, getState) => {
checkTxList();
}, 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;
$thumbnailWidthSmall: 1rem;
.comment__create {
.content_comment {
position: relative;
}
.commentCreate {
font-size: var(--font-small);
position: relative;
@ -19,16 +23,12 @@ $thumbnailWidthSmall: 1rem;
}
}
.comment__create--reply {
.commentCreate--reply {
margin-top: var(--spacing-m);
position: relative;
}
.content_comment {
position: relative;
}
.comment__create--nested-reply {
.commentCreate--nestedReply {
margin-top: var(--spacing-s);
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;
}
.comment-new__label-wrapper {
.commentCreate__labelWrapper {
display: flex;
flex-direction: row;
justify-content: flex-start;
@ -49,6 +49,11 @@ $thumbnailWidthSmall: 1rem;
flex-wrap: wrap;
width: 100%;
.commentCreate__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
@media (min-width: $breakpoint-small) {
fieldset-section {
max-width: 10rem;
@ -56,27 +61,50 @@ $thumbnailWidthSmall: 1rem;
}
}
.comment-new__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
.comment__sc-preview {
.commentCreate__supportCommentPreview {
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-s);
margin: var(--spacing-s) 0;
}
.comment__sc-preview-amount {
.commentCreate__supportCommentPreviewAmount {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
}
.comment--min-amount-notice {
.commentCreate__minAmountNotice {
.icon {
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);
}
.comment__tip-input {
margin: var(--spacing-s) 0;
}
.comment--blocked {
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 {
background-color: var(--color-card-background-highlighted);
+ .livestream-comment--superchat {
margin-bottom: var(--spacing-xxs);
}
@ -139,11 +141,15 @@ $recent-msg-button__height: 2rem;
.livestream-comment__body {
display: flex;
flex-direction: column;
flex: 2;
align-items: flex-start;
margin-left: var(--spacing-s);
overflow: hidden;
.livestream-comment__info--sticker {
display: flex;
margin: var(--spacing-xxs) 0;
}
.channel-thumbnail {
@include handleChannelGif(2rem);
margin-top: var(--spacing-xxs);
@ -338,6 +344,28 @@ $recent-msg-button__height: 2rem;
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 {
border-top-left-radius: 0;
border-bottom-left-radius: 0;

View file

@ -15,7 +15,7 @@ $contentMaxWidth: 60rem;
}
}
.comment__create,
.commentCreate,
.comment__content {
margin: var(--spacing-m);
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 {
display: flex;
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
import * as REACTION_TYPES from 'constants/reactions';
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
// 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;
});
}
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>', '');
})
);
}