lbry-desktop/ui/component/commentCreate/view.jsx
infinite-persistence 26f9cf3a4f Undo the channel-clamping and let user figure out from Toast
Several issues with the clamping behavior:
- Problems trying to sync with the activeChannel setting.
- Corner-cases like unable to un-react because the comment and react channel was different; global mods not be able to change channels to do certain actions.

Just let the user know what are the channel(s) that they used to comment previously in the Toast.
2022-05-06 11:31:42 -04:00

724 lines
24 KiB
JavaScript

// @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 { Lbryio } from 'lbryinc';
import { SIMPLE_SITE } from 'config';
import { useHistory } from 'react-router';
import * as ICONS from 'constants/icons';
import * as KEYCODES from 'constants/keycodes';
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import Button from 'component/button';
import classnames from 'classnames';
import CommentSelectors, { SELECTOR_TABS } from './comment-selectors';
import React from 'react';
import type { ElementRef } from 'react';
import usePersistedState from 'effects/use-persisted-state';
import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { useIsMobile } from 'effects/use-screensize';
import { StickerReviewBox, StickerActionButton } from './sticker-contents';
import { TipReviewBox, TipActionButton } from './tip-contents';
import { FormChannelSelector, HelpText } from './extra-contents';
import { getStripeEnvironment } from 'util/stripe';
const stripeEnvironment = getStripeEnvironment();
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string };
type UserParams = { activeChannelName: ?string, activeChannelId: ?string };
type Props = {
activeChannelClaimId?: string,
activeChannelName?: string,
activeChannelUrl?: string,
bottom: boolean,
hasChannels: boolean,
claimId?: string,
channelClaimId?: string,
tipChannelName?: string,
claimIsMine: boolean,
embed?: boolean,
isFetchingChannels: boolean,
isNested: boolean,
isReply: boolean,
isLivestream?: boolean,
parentId: string,
settingsByChannelId: { [channelId: string]: PerChannelSettings },
shouldFetchComment: boolean,
supportDisabled: boolean,
uri: string,
disableInput?: boolean,
onSlimInputClose?: () => void,
setQuickReply: (any) => void,
onCancelReplying?: () => void,
onDoneReplying?: () => void,
// redux
doCommentCreate: (uri: string, isLivestream?: boolean, params: CommentSubmitParams) => Promise<any>,
doFetchCreatorSettings: (channelId: string) => Promise<any>,
doToast: ({ message: string }) => void,
doCommentById: (commentId: string, toastIfNotFound: boolean) => Promise<any>,
doSendCashTip: (
TipParams,
anonymous: boolean,
UserParams,
claimId: string,
stripe: ?string,
preferredCurrency: string,
(any) => void
) => string,
doSendTip: (params: {}, isSupport: boolean, successCb: (any) => void, errorCb: (any) => void, boolean) => void,
doOpenModal: (id: string, any) => void,
preferredCurrency: string,
};
export function CommentCreate(props: Props) {
const {
activeChannelClaimId,
activeChannelName,
activeChannelUrl,
bottom,
hasChannels,
claimId,
channelClaimId,
tipChannelName,
claimIsMine,
embed,
isFetchingChannels,
isNested,
isReply,
isLivestream,
parentId,
settingsByChannelId,
shouldFetchComment,
supportDisabled,
uri,
disableInput,
onSlimInputClose,
doCommentCreate,
doFetchCreatorSettings,
doToast,
doCommentById,
onCancelReplying,
onDoneReplying,
doSendCashTip,
doSendTip,
setQuickReply,
doOpenModal,
preferredCurrency,
} = props;
const isMobile = useIsMobile();
const formFieldRef: ElementRef<any> = React.useRef();
const buttonRef: ElementRef<any> = React.useRef();
const slimInputButtonRef: ElementRef<any> = React.useRef();
const {
push,
location: { pathname },
} = useHistory();
const [isSubmitting, setSubmitting] = React.useState(false);
const [commentFailure, setCommentFailure] = React.useState(false);
const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined });
const [tipSelectorOpen, setTipSelector] = 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 [activeTab, setActiveTab] = React.useState();
const [tipError, setTipError] = React.useState();
const [deletedComment, setDeletedComment] = React.useState(false);
const [showSelectors, setShowSelectors] = React.useState({ tab: undefined, open: false });
const [disableReviewButton, setDisableReviewButton] = React.useState();
const [exchangeRate, setExchangeRate] = React.useState();
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(undefined);
const [tipModalOpen, setTipModalOpen] = React.useState(undefined);
const charCount = commentValue ? commentValue.length : 0;
const hasNothingToSumbit = !commentValue.length && !selectedSticker;
const disabled = deletedComment || isSubmitting || isFetchingChannels || hasNothingToSumbit || disableInput;
const channelSettings = channelClaimId ? settingsByChannelId[channelClaimId] : 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 = activeTab !== TAB_LBC || minAmount === 0 || tipAmount >= minAmount;
const stickerPrice = selectedSticker && selectedSticker.price;
const tipSelectorError = tipError || disableReviewButton;
const minAmountRef = React.useRef(minAmount);
minAmountRef.current = minAmount;
// **************************************************************************
// Functions
// **************************************************************************
function addEmoteToComment(emote: string) {
setCommentValue(
commentValue + (commentValue && commentValue.charAt(commentValue.length - 1) !== ' ' ? ` ${emote} ` : `${emote} `)
);
}
function handleSelectTipComment(tab: string) {
setActiveTab(tab);
if (isMobile) {
setTipModalOpen(true);
doOpenModal(MODALS.SEND_TIP, {
uri,
isTipOnly: true,
hasSelectedTab: tab,
customText: __('Preview Comment Tip'),
setAmount: (amount) => {
setTipAmount(amount);
setReviewingSupportComment(true);
},
});
} else {
setTipSelector(true);
}
if (onSlimInputClose) onSlimInputClose();
}
function handleStickerComment() {
if (selectedSticker) {
setReviewingStickerComment(false);
setSelectedSticker(undefined);
setShowSelectors({ tab: SELECTOR_TABS.STICKER, open: true });
} else {
setShowSelectors({ tab: showSelectors.tab || undefined, open: !showSelectors.open });
}
setTipSelector(false);
}
function handleSelectSticker(sticker: any) {
// $FlowFixMe
setSelectedSticker(sticker);
setReviewingStickerComment(true);
setTipAmount(sticker.price || 0);
setShowSelectors({ tab: showSelectors.tab || undefined, open: false });
// added this here since selecting a sticker can cause scroll issues
if (onSlimInputClose) onSlimInputClose();
if (sticker.price && sticker.price > 0) {
setActiveTab(canReceiveFiatTip ? TAB_FIAT : TAB_LBC);
setTipSelector(true);
}
}
function handleCancelSticker() {
setReviewingStickerComment(false);
setSelectedSticker(null);
if (onSlimInputClose) onSlimInputClose();
}
function handleCancelSupport() {
if (!isReviewingSupportComment) setTipSelector(false);
setReviewingSupportComment(false);
if (stickerPrice) {
setReviewingStickerComment(false);
setShowSelectors({ tab: showSelectors.tab || undefined, open: false });
setSelectedSticker(null);
}
if (onSlimInputClose) onSlimInputClose();
}
function handleSupportComment() {
if (!activeChannelClaimId) return;
if (!channelClaimId) {
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(channelClaimId).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,
});
setReviewingSupportComment(false);
return;
}
doSubmitTip();
});
}
function doSubmitTip() {
if (!claimId || !channelClaimId || !activeChannelName || !activeChannelClaimId || isSubmitting || !tipChannelName) {
return;
}
setSubmitting(true);
const params = { amount: tipAmount, claim_id: claimId, channel_id: activeChannelClaimId };
if (activeTab === TAB_LBC) {
// call doSendTip and then run the callback from the response
// second parameter is callback
doSendTip(
params,
false,
(response) => {
const { txid } = response;
// todo: why the setTimeout?
setTimeout(() => {
handleCreateComment(txid);
}, 1500);
doToast({
message: __("Tip successfully sent. I'm sure they appreciate it!"),
subMessage: `${tipAmount} LBC ⇒ ${tipChannelName}`, // force show decimal places
});
setSuccessTip({ txid, tipAmount });
},
() => {
// reset the frontend so people can send a new comment
setSubmitting(false);
},
false
);
} else {
const tipParams: TipParams = { tipAmount: Math.round(tipAmount * 100) / 100, tipChannelName, channelClaimId };
const userParams: UserParams = { activeChannelName, activeChannelId: activeChannelClaimId };
doSendCashTip(
tipParams,
false,
userParams,
claimId,
stripeEnvironment,
preferredCurrency,
(customerTipResponse) => {
const { payment_intent_id } = customerTipResponse;
handleCreateComment(null, payment_intent_id, stripeEnvironment);
setCommentValue('');
setReviewingSupportComment(false);
setTipSelector(false);
setCommentFailure(false);
setSubmitting(false);
}
);
}
}
/**
*
* @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) {
if (isSubmitting || disableInput || !claimId) return;
setSubmitting(true);
const stickerValue = selectedSticker && buildValidSticker(selectedSticker.name);
doCommentCreate(uri, isLivestream, {
comment: stickerValue || commentValue,
claim_id: claimId,
parent_id: parentId,
txid,
payment_intent_id,
environment,
sticker: !!stickerValue,
})
.then((res) => {
setSubmitting(false);
if (setQuickReply) setQuickReply(res);
if (res && res.signature) {
if (!stickerValue) setCommentValue('');
setReviewingSupportComment(false);
setTipSelector(false);
setCommentFailure(false);
if (onDoneReplying) {
onDoneReplying();
}
}
})
.catch(() => {
setSubmitting(false);
setCommentFailure(true);
if (channelClaimId) {
// It could be that the creator added a minimum tip setting.
// Manually update for now until a websocket msg is available.
doFetchCreatorSettings(channelClaimId);
}
});
}
function handleSubmitSticker() {
if (isReviewingSupportComment) {
handleSupportComment();
} else {
handleCreateComment();
}
setSelectedSticker(null);
setReviewingStickerComment(false);
setShowSelectors({ tab: showSelectors.tab || undefined, open: false });
setTipSelector(false);
}
let fiatIconToUse = ICONS.FINANCE;
if (preferredCurrency === 'EUR') fiatIconToUse = ICONS.EURO;
// **************************************************************************
// Effects
// **************************************************************************
// Fetch channel constraints if not already.
React.useEffect(() => {
if (!channelSettings && channelClaimId) {
doFetchCreatorSettings(channelClaimId);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// change sticker selection
React.useEffect(() => {
if (isMobile && showSelectors.tab && slimInputButtonRef && slimInputButtonRef.current) {
slimInputButtonRef.current.click();
}
}, [isMobile, showSelectors.tab]);
// Notifications: Fetch top-level comments to identify if it has been deleted and can reply to it
React.useEffect(() => {
if (shouldFetchComment && doCommentById) {
doCommentById(parentId, false).then((result) => {
setDeletedComment(String(result).includes('Error'));
});
}
}, [doCommentById, shouldFetchComment, parentId]);
// Stickers: Get LBC-USD exchange rate if hasn't yet and selected a paid sticker
React.useEffect(() => {
if (stickerPrice && !exchangeRate) Lbryio.getExchangeRates().then(({ LBC_USD }) => setExchangeRate(LBC_USD));
}, [exchangeRate, stickerPrice]);
// Stickers: Check if creator has a tip account saved (on selector so that if a paid sticker is selected,
// it defaults to LBC tip instead of USD)
React.useEffect(() => {
if (!stripeEnvironment || canReceiveFiatTip !== undefined || !tipChannelName) return;
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(() => {});
}, [canReceiveFiatTip, channelClaimId, tipChannelName]);
// Handle keyboard shortcut comment creation
React.useEffect(() => {
function altEnterListener(e: SyntheticKeyboardEvent<*>) {
const inputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input;
if (inputRef && inputRef.current === document.activeElement) {
// $FlowFixMe
const isTyping = Boolean(e.target.attributes['typing-term']);
if (((isLivestream && !isTyping) || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
e.preventDefault();
buttonRef.current.click();
}
if (isLivestream && isTyping && e.keyCode === KEYCODES.ENTER) {
inputRef.current.removeAttribute('typing-term');
}
}
}
window.addEventListener('keydown', altEnterListener);
// removes the listener so it doesn't cause problems elsewhere in the app
return () => {
window.removeEventListener('keydown', altEnterListener);
};
}, [isLivestream]);
// **************************************************************************
// Render
// **************************************************************************
if (!isFetchingChannels && !hasChannels) {
return (
<div
role="button"
className="comment-create__auth"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (embed) {
window.open(`https://odysee.com/$/${PAGES.AUTH}?redirect=/$/${PAGES.LIVESTREAM}`);
return;
}
const pathPlusRedirect = `/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`;
if (isLivestream) {
window.open(pathPlusRedirect);
} else {
push(pathPlusRedirect);
}
}}
>
<FormField
type="textarea"
name="comment__signup-prompt"
placeholder={__('Say something about this...')}
disabled={isMobile}
/>
{!isMobile && (
<div className="section__actions--no-margin">
<Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth />
</div>
)}
</div>
);
}
const commentSelectorsProps = {
claimIsMine,
addEmoteToComment,
handleSelectSticker,
openTab: showSelectors.tab || undefined,
};
const submitButtonProps = { button: 'primary', type: 'submit', requiresAuth: true };
const actionButtonProps = { button: 'alt' };
const tipButtonProps = {
...actionButtonProps,
disabled: !commentValue.length && !selectedSticker,
tipSelectorOpen,
activeTab,
onClick: handleSelectTipComment,
};
const cancelButtonProps = { button: 'link', label: __('Cancel') };
const stickerReviewProps = {
activeChannelUrl,
src: selectedSticker ? selectedSticker.url : '',
price: selectedSticker ? selectedSticker.price : 0,
exchangeRate,
};
return (
<Form
onSubmit={() => {}}
className={classnames('commentCreate', {
'commentCreate--reply': isReply,
'commentCreate--nestedReply': isNested,
'commentCreate--bottom': bottom,
})}
>
{isReviewingSupportComment ? (
activeChannelUrl &&
activeTab && (
<TipReviewBox
activeChannelUrl={activeChannelUrl}
tipAmount={tipAmount}
activeTab={activeTab}
message={commentValue}
isReviewingStickerComment={isReviewingStickerComment}
stickerPreviewComponent={selectedSticker && <StickerReviewBox {...stickerReviewProps} />}
/>
)
) : selectedSticker ? (
activeChannelUrl && <StickerReviewBox {...stickerReviewProps} />
) : (
<>
{!isMobile && showSelectors.open && (
<CommentSelectors
{...commentSelectorsProps}
closeSelector={() => setShowSelectors({ tab: showSelectors.tab || undefined, open: false })}
/>
)}
<FormField
autoFocus={isReply}
charCount={charCount}
className={isReply ? 'create__reply' : 'create__comment'}
disabled={isFetchingChannels || disableInput}
isLivestream={isLivestream}
label={<FormChannelSelector isReply={Boolean(isReply)} isLivestream={Boolean(isLivestream)} />}
noticeLabel={
isMobile && (
<HelpText deletedComment={deletedComment} minAmount={minAmount} minSuper={minSuper} minTip={minTip} />
)
}
name={isReply ? 'create__reply' : 'create__comment'}
onChange={(e) => setCommentValue(SIMPLE_SITE || !advancedEditor || isReply ? e.target.value : e)}
handleTip={(isLBC) => handleSelectTipComment(isLBC ? TAB_LBC : TAB_FIAT)}
handleSubmit={handleCreateComment}
slimInput={isMobile && uri} // "uri": make sure it's on a file page
slimInputButtonRef={slimInputButtonRef}
onSlimInputClose={onSlimInputClose}
commentSelectorsProps={commentSelectorsProps}
submitButtonRef={buttonRef}
setShowSelectors={setShowSelectors}
showSelectors={showSelectors}
tipModalOpen={tipModalOpen}
placeholder={__('Say something about this...')}
quickActionHandler={!SIMPLE_SITE ? () => setAdvancedEditor(!advancedEditor) : undefined}
quickActionLabel={
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
}
ref={formFieldRef}
textAreaMaxLength={isLivestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
type={!SIMPLE_SITE && advancedEditor && !isReply ? 'markdown' : 'textarea'}
value={commentValue}
uri={uri}
/>
</>
)}
{(!isMobile || isReviewingStickerComment) && (tipSelectorOpen || (isReviewingStickerComment && stickerPrice)) && (
<WalletTipAmountSelector
activeTab={activeTab}
amount={tipAmount}
uri={uri}
convertedAmount={convertedAmount}
customTipAmount={stickerPrice}
exchangeRate={exchangeRate}
fiatConversion={selectedSticker && !!selectedSticker.price}
onChange={(amount) => setTipAmount(amount)}
setConvertedAmount={setConvertedAmount}
setDisableSubmitButton={setDisableReviewButton}
setTipError={setTipError}
tipError={tipError}
/>
)}
{(!isMobile || isReviewingStickerComment || isReviewingSupportComment) && (
<div className="section__actions">
{/* Submit Button */}
{isReviewingSupportComment ? (
<Button
{...submitButtonProps}
autoFocus
disabled={disabled || !minAmountMet}
label={
isSubmitting
? __('Sending...')
: commentFailure && tipAmount === successTip.tipAmount
? __('Re-submit')
: __('Send')
}
onClick={handleSupportComment}
/>
) : tipSelectorOpen ? (
<Button
{...submitButtonProps}
disabled={disabled || tipSelectorError || !minAmountMet}
icon={activeTab === TAB_LBC ? ICONS.LBC : fiatIconToUse}
label={__('Review')}
onClick={() => {
setReviewingSupportComment(true);
if (onSlimInputClose) onSlimInputClose();
}}
/>
) : (
(!isMobile || selectedSticker) &&
(!minTip || claimIsMine) && (
<Button
{...submitButtonProps}
ref={buttonRef}
disabled={disabled}
label={
isReply
? isSubmitting
? __('Replying...')
: __('Reply')
: isSubmitting
? __('Commenting...')
: __('Comment --[button to submit something]--')
}
onClick={() => (selectedSticker ? handleSubmitSticker() : handleCreateComment())}
/>
)
)}
{(!isMobile || isReviewingStickerComment) && (
<>
<StickerActionButton
{...actionButtonProps}
isReviewingStickerComment={isReviewingStickerComment}
icon={ICONS.STICKER}
onClick={handleStickerComment}
/>
{!supportDisabled && !claimIsMine && (
<>
<TipActionButton {...tipButtonProps} name={__('Credits')} icon={ICONS.LBC} tab={TAB_LBC} />
{stripeEnvironment && (
<TipActionButton {...tipButtonProps} name={__('Cash')} icon={fiatIconToUse} tab={TAB_FIAT} />
)}
</>
)}
</>
)}
{tipSelectorOpen || isReviewingSupportComment ? (
<Button {...cancelButtonProps} disabled={isSubmitting} onClick={handleCancelSupport} />
) : isReviewingStickerComment ? (
<Button {...cancelButtonProps} onClick={handleCancelSticker} />
) : (
onCancelReplying && <Button {...cancelButtonProps} onClick={onCancelReplying} />
)}
<HelpText deletedComment={deletedComment} minAmount={minAmount} minSuper={minSuper} minTip={minTip} />
</div>
)}
</Form>
);
}