// @flow import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field'; import 'scss/component/_comment-create.scss'; import { FormField, Form } from 'component/common/form'; import { getChannelIdFromClaim } from 'util/claim'; import { Lbryio } from 'lbryinc'; import { useHistory } from 'react-router'; import * as ICONS from 'constants/icons'; import * as KEYCODES from 'constants/keycodes'; import * as PAGES from 'constants/pages'; import Button from 'component/button'; import ChannelMentionSuggestions from 'component/channelMentionSuggestions'; import ChannelThumbnail from 'component/channelThumbnail'; import classnames from 'classnames'; import CreditAmount from 'component/common/credit-amount'; import EmoteSelector from './emote-selector'; import Empty from 'component/common/empty'; import I18nMessage from 'component/i18nMessage'; import Icon from 'component/common/icon'; import React from 'react'; import SelectChannel from 'component/selectChannel'; 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 TAB_FIAT = 'TabFiat'; const TAB_LBC = 'TabLBC'; const MENTION_DEBOUNCE_MS = 100; type Props = { uri: string, claim: StreamClaim, hasChannels: boolean, isNested: boolean, isFetchingChannels: boolean, parentId: string, isReply: boolean, activeChannel: string, activeChannelClaim: ?ChannelClaim, bottom: boolean, embed?: boolean, claimIsMine: boolean, supportDisabled: boolean, settingsByChannelId: { [channelId: string]: PerChannelSettings }, shouldFetchComment: boolean, doToast: ({ message: string }) => void, createComment: (string, string, string, ?string, ?string, ?string) => Promise, onDoneReplying?: () => void, onCancelReplying?: () => void, toast: (string) => void, sendTip: ({}, (any) => void, (any) => void) => void, doFetchCreatorSettings: (channelId: string) => Promise, setQuickReply: (any) => void, fetchComment: (commentId: string) => Promise, }; export function CommentCreate(props: Props) { const { uri, claim, hasChannels, isNested, isFetchingChannels, isReply, parentId, activeChannelClaim, bottom, claimIsMine, settingsByChannelId, supportDisabled, shouldFetchComment, doToast, createComment, onDoneReplying, onCancelReplying, sendTip, doFetchCreatorSettings, setQuickReply, fetchComment, } = props; const formFieldRef: ElementRef = React.useRef(); const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input; const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart; const buttonRef: ElementRef = React.useRef(); const { push, location: { pathname }, } = useHistory(); const [isSubmitting, setIsSubmitting] = 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 [tipAmount, setTipAmount] = React.useState(1); const [commentValue, setCommentValue] = React.useState(''); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); 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 selectedMentionIndex = commentValue.indexOf('@', selectionIndex) === selectionIndex ? commentValue.indexOf('@', selectionIndex) : commentValue.lastIndexOf('@', selectionIndex); const modifierIndex = commentValue.indexOf(':', selectedMentionIndex); const spaceIndex = commentValue.indexOf(' ', selectedMentionIndex); const mentionLengthIndex = modifierIndex >= 0 && (spaceIndex === -1 || modifierIndex < spaceIndex) ? modifierIndex : spaceIndex >= 0 && (modifierIndex === -1 || spaceIndex < modifierIndex) ? spaceIndex : commentValue.length; const channelMention = selectedMentionIndex >= 0 && selectionIndex <= mentionLengthIndex ? commentValue.substring(selectedMentionIndex, mentionLengthIndex) : ''; const claimId = claim && claim.claim_id; const signingChannel = (claim && claim.signing_channel) || claim; const channelUri = signingChannel && signingChannel.permanent_url; const charCount = commentValue ? commentValue.length : 0; const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend; const channelId = getChannelIdFromClaim(claim); const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; const minTip = (channelSettings && channelSettings.min_tip_amount_comment) || 0; const minAmount = minTip || minSuper || 0; const minAmountMet = minAmount === 0 || tipAmount >= minAmount; const minAmountRef = React.useRef(minAmount); minAmountRef.current = minAmount; const MinAmountNotice = minAmount ? (
}}> {minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
) : null; // ************************************************************************** // Functions // ************************************************************************** function handleCommentChange(event) { let commentValue; if (isReply) { commentValue = advancedEditor ? event : event.target.value; } else { commentValue = advancedEditor ? event : event.target.value; } setCommentValue(commentValue); } function handleSelectMention(mentionValue, key) { let newMentionValue = mentionValue.replace('lbry://', ''); if (newMentionValue.includes('#')) newMentionValue = newMentionValue.replace('#', ':'); setCommentValue( commentValue.substring(0, selectedMentionIndex) + `${newMentionValue}` + (commentValue.length > mentionLengthIndex + 1 ? commentValue.substring(mentionLengthIndex, commentValue.length) : ' ') ); } function altEnterListener(e: SyntheticKeyboardEvent<*>) { if ((e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) { e.preventDefault(); buttonRef.current.click(); } } function onTextareaFocus() { window.addEventListener('keydown', altEnterListener); } function onTextareaBlur() { window.removeEventListener('keydown', altEnterListener); } function handleSupportComment() { if (!activeChannelClaim) { return; } if (!channelId) { doToast({ message: __('Unable to verify channel settings. Try refreshing the page.'), isError: true, }); return; } // if comment post didn't work, but tip was already made, try again to create comment if (commentFailure && tipAmount === successTip.tipAmount) { handleCreateComment(successTip.txid); return; } else { setSuccessTip({ txid: undefined, tipAmount: undefined }); } // !! Beware of stale closure when editing the then-block, including doSubmitTip(). doFetchCreatorSettings(channelId).then(() => { const lockedMinAmount = minAmount; // value during closure. const currentMinAmount = minAmountRef.current; // value from latest doFetchCreatorSettings(). if (lockedMinAmount !== currentMinAmount) { doToast({ message: __('The creator just updated the minimum setting. Please revise or double-check your tip amount.'), isError: true, }); setIsReviewingSupportComment(false); return; } doSubmitTip(); }); } function doSubmitTip() { 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; // otherwise it's on the channel page } else { channelClaimId = claim.claim_id; tipChannelName = claim.name; } setIsSubmitting(true); if (activeTab === TAB_LBC) { // call sendTip and then run the callback from the response // second parameter is callback sendTip( params, (response) => { const { txid } = response; // todo: why the setTimeout? setTimeout(() => { handleCreateComment(txid); }, 1500); doToast({ message: __( "You sent %tipAmount% LBRY Credits as a tip to %tipChannelName%, I'm sure they appreciate it!", { tipAmount: tipAmount, // force show decimal places tipChannelName, } ), }); setSuccessTip({ txid, tipAmount }); }, () => { // reset the frontend so people can send a new comment setIsSubmitting(false); } ); } else { const sourceClaimId = claim.claim_id; const roundedAmount = Math.round(tipAmount * 100) / 100; 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; handleCreateComment(null, paymentIntendId, stripeEnvironment); setCommentValue(''); setIsReviewingSupportComment(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, }); }); } } /** * * @param {string} [txid] Optional transaction id generated by * @param {string} [payment_intent_id] Optional payment_intent_id from Stripe payment * @param {string} [environment] Optional environment for Stripe (test|live) */ function handleCreateComment(txid, payment_intent_id, environment) { setShowEmotes(false); setIsSubmitting(true); createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment) .then((res) => { setIsSubmitting(false); if (setQuickReply) setQuickReply(res); if (res && res.signature) { setCommentValue(''); setIsReviewingSupportComment(false); setIsSupportComment(false); setCommentFailure(false); if (onDoneReplying) { onDoneReplying(); } } }) .catch(() => { setIsSubmitting(false); setCommentFailure(true); if (channelId) { // It could be that the creator added a minimum tip setting. // Manually update for now until a websocket msg is available. doFetchCreatorSettings(channelId); } }); } // ************************************************************************** // Effects // ************************************************************************** // Fetch channel constraints if not already. React.useEffect(() => { if (!channelSettings && channelId) { doFetchCreatorSettings(channelId); } }, []); // eslint-disable-line react-hooks/exhaustive-deps // Notifications: Fetch top-level comments to identify if it has been deleted and can reply to it React.useEffect(() => { if (shouldFetchComment && fetchComment) { fetchComment(parentId).then((result) => { setDeletedComment(String(result).includes('Error')); }); } }, [fetchComment, shouldFetchComment, parentId]); // Debounce for disabling the submit button when mentioning a user with Enter // so that the comment isn't sent at the same time React.useEffect(() => { const timer = setTimeout(() => { if (pauseQuickSend) { setPauseQuickSend(false); } }, MENTION_DEBOUNCE_MS); return () => clearTimeout(timer); }, [pauseQuickSend]); // ************************************************************************** // Render // ************************************************************************** if (channelSettings && !channelSettings.comments_enabled) { return ; } if (!isFetchingChannels && !hasChannels) { return (
{ const pathPlusRedirect = `/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`; push(pathPlusRedirect); }} >
); } if (isReviewingSupportComment && activeChannelClaim) { return (
{commentValue}
); } return (
{showEmotes && ( setShowEmotes(false)} /> )} {!advancedEditor && ( )}
{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}
} quickActionLabel={isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')} quickActionHandler={() => setAdvancedEditor(!advancedEditor)} openEmoteMenu={() => setShowEmotes(!showEmotes)} onFocus={onTextareaFocus} onBlur={onTextareaBlur} placeholder={__('Say something about this...')} value={commentValue} charCount={charCount} onChange={handleCommentChange} autoFocus={isReply} textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT} /> {isSupportComment && ( setTipAmount(amount)} /> )}
{isSupportComment ? ( <>
); }