Add CommentCreate to Modal on Mobile

- Move stickers and emojis to a single menu comment-selectors on both mobile and desktop
- More style improvements
- Some fixes
- Fix livechat scrolling
This commit is contained in:
Rafael 2022-02-04 17:59:11 -03:00 committed by Thomas Zarebczan
parent eef6691557
commit c90efc2078
34 changed files with 1214 additions and 696 deletions

View file

@ -21,6 +21,7 @@
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="preload" href="/public/font/v1/300.woff" as="font" type="font/woff" crossorigin /> <link rel="preload" href="/public/font/v1/300.woff" as="font" type="font/woff" crossorigin />
<link rel="preload" href="/public/font/v1/300i.woff" as="font" type="font/woff" crossorigin /> <link rel="preload" href="/public/font/v1/300i.woff" as="font" type="font/woff" crossorigin />

View file

@ -213,17 +213,27 @@ function Comment(props: Props) {
replace(`${pathname}?${urlParams.toString()}`); replace(`${pathname}?${urlParams.toString()}`);
} }
const linkedCommentRef = React.useCallback((node) => { const linkedCommentRef = React.useCallback(
(node) => {
if (node !== null && window.pendingLinkedCommentScroll) { if (node !== null && window.pendingLinkedCommentScroll) {
const ROUGH_HEADER_HEIGHT = 125; // @see: --header-height const ROUGH_HEADER_HEIGHT = 125; // @see: --header-height
delete window.pendingLinkedCommentScroll; delete window.pendingLinkedCommentScroll;
window.scrollTo({
top: node.getBoundingClientRect().top + window.scrollY - ROUGH_HEADER_HEIGHT, const elem = isMobile ? document.querySelector('.MuiPaper-root .card--enable-overflow') : window;
if (elem) {
// $FlowFixMe
elem.scrollTo({
// $FlowFixMe
top: node.getBoundingClientRect().top + elem.scrollY - (isMobile ? 0 : ROUGH_HEADER_HEIGHT),
left: 0, left: 0,
behavior: 'smooth', behavior: 'smooth',
}); });
} }
}, []); }
},
[isMobile]
);
return ( return (
<li <li
@ -313,6 +323,7 @@ function Comment(props: Props) {
charCount={charCount} charCount={charCount}
onChange={handleEditMessageChanged} onChange={handleEditMessageChanged}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT} textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
handleSubmit={handleSubmit}
/> />
<div className="section__actions section__actions--no-margin"> <div className="section__actions section__actions--no-margin">
<Button <Button

View file

@ -0,0 +1,150 @@
// @flow
import 'scss/component/_comment-selectors.scss';
import { EMOTES_48px as EMOTES } from 'constants/emotes';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import CreditAmount from 'component/common/credit-amount';
import React from 'react';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import { FREE_GLOBAL_STICKERS, PAID_GLOBAL_STICKERS } from 'constants/stickers';
type Props = {
claimIsMine?: boolean,
addEmoteToComment: (string) => void,
handleSelectSticker: (any) => void,
closeSelector?: () => void,
};
export default function CommentSelectors(props: Props) {
const { claimIsMine, addEmoteToComment, handleSelectSticker, closeSelector } = props;
const tabProps = { closeSelector };
return (
<Tabs>
<TabList className="tabs__list--comment-selector">
<Tab>{__('Emojis')}</Tab>
<Tab>{__('Stickers')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<EmojisPanel handleSelect={(emote) => addEmoteToComment(emote)} {...tabProps} />
</TabPanel>
<TabPanel>
<StickersPanel
handleSelect={(sticker) => handleSelectSticker(sticker)}
claimIsMine={claimIsMine}
{...tabProps}
/>
</TabPanel>
</TabPanels>
</Tabs>
);
}
type EmojisProps = {
handleSelect: (emoteName: string) => void,
closeSelector: () => void,
};
const EmojisPanel = (emojisProps: EmojisProps) => {
const { handleSelect, closeSelector } = emojisProps;
return (
<div className="selector-menu">
<Button button="close" icon={ICONS.REMOVE} onClick={closeSelector} />
<div className="emote-selector__items">
{EMOTES.map((emote) => {
const { name, url } = emote;
return (
<Button
key={name}
title={name}
button="alt"
className="button--file-action"
onClick={() => handleSelect(name)}
>
<img src={url} loading="lazy" />
</Button>
);
})}
</div>
</div>
);
};
type StickersProps = {
claimIsMine: any,
handleSelect: (any) => void,
closeSelector: () => void,
};
const StickersPanel = (stickersProps: StickersProps) => {
const { claimIsMine, handleSelect, closeSelector } = stickersProps;
const defaultRowProps = { handleSelect };
return (
<div className="selector-menu--stickers">
<Button button="close" icon={ICONS.REMOVE} onClick={closeSelector} />
<StickersRow title={__('Free')} stickers={FREE_GLOBAL_STICKERS} {...defaultRowProps} />
{!claimIsMine && <StickersRow title={__('Tips')} stickers={PAID_GLOBAL_STICKERS} {...defaultRowProps} />}
</div>
);
};
type RowProps = {
title: string,
stickers: any,
handleSelect: (string) => void,
};
const StickersRow = (rowProps: RowProps) => {
const { title, stickers, handleSelect } = rowProps;
return (
<div className="sticker-selector__body-row">
<label id={title} className="sticker-selector__row-title">
{title}
</label>
<div className="sticker-selector__items">
{stickers.map((sticker) => {
const { price, url, name } = sticker;
return (
<Button
key={name}
title={name}
button="alt"
className="button--file-action"
onClick={() => handleSelect(sticker)}
>
<StickerWrapper price={price}>
<img src={url} loading="lazy" />
{price && price > 0 && <CreditAmount superChatLight amount={price} size={2} isFiat />}
</StickerWrapper>
</Button>
);
})}
</div>
</div>
);
};
type StickerProps = {
price?: number,
children: any,
};
const StickerWrapper = (stickerProps: StickerProps) => {
const { price, children } = stickerProps;
return price ? <div className="sticker-item--priced">{children}</div> : children;
};

View file

@ -1,66 +0,0 @@
// @flow
import 'scss/component/_emote-selector.scss';
import { EMOTES_48px as EMOTES } from 'constants/emotes';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import EMOJIS from 'emoji-dictionary';
import OptimizedImage from 'component/optimizedImage';
import React from 'react';
const OLD_QUICK_EMOJIS = [
EMOJIS.getUnicode('rocket'),
EMOJIS.getUnicode('jeans'),
EMOJIS.getUnicode('fire'),
EMOJIS.getUnicode('heart'),
EMOJIS.getUnicode('open_mouth'),
];
type Props = { commentValue: string, setCommentValue: (string) => void, closeSelector: () => void };
export default function EmoteSelector(props: Props) {
const { commentValue, setCommentValue, closeSelector } = props;
function addEmoteToComment(emote: string) {
setCommentValue(
commentValue + (commentValue && commentValue.charAt(commentValue.length - 1) !== ' ' ? ` ${emote} ` : `${emote} `)
);
}
return (
<div className="emoteSelector">
<Button button="close" icon={ICONS.REMOVE} onClick={closeSelector} />
<div className="emoteSelector__list">
<div className="emoteSelector__listRow">
<div className="emoteSelector__listRowItems">
{OLD_QUICK_EMOJIS.map((emoji) => (
<Button
key={emoji}
label={emoji}
title={`:${EMOJIS.getName(emoji)}:`}
button="alt"
className="button--file-action"
onClick={() => addEmoteToComment(emoji)}
/>
))}
{EMOTES.map((emote) => {
const emoteName = emote.name;
return (
<Button
key={emoteName}
title={emoteName}
button="alt"
className="button--file-action"
onClick={() => addEmoteToComment(emoteName)}
>
<OptimizedImage src={emote.url} waitLoad />
</Button>
);
})}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
// @flow
import 'scss/component/_comment-selectors.scss';
import React from 'react';
import * as ICONS from 'constants/icons';
import CreditAmount from 'component/common/credit-amount';
import I18nMessage from 'component/i18nMessage';
import Icon from 'component/common/icon';
import SelectChannel from 'component/selectChannel';
type SelectorProps = {
isReply: boolean,
isLivestream: boolean,
};
export const FormChannelSelector = (selectorProps: SelectorProps) => {
const { isReply, isLivestream } = selectorProps;
return (
<div className="comment-create__label-wrapper">
<span className="comment-create__label">
{(isReply ? __('Replying as') : isLivestream ? __('Chat as') : __('Comment as')) + ' '}
</span>
<SelectChannel tiny />
</div>
);
};
type HelpTextProps = {
deletedComment: boolean,
minAmount: number,
minSuper: number,
minTip: number,
};
export const HelpText = (helpTextProps: HelpTextProps) => {
const { deletedComment, minAmount, minSuper, minTip } = helpTextProps;
return (
<>
{deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>}
{!!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>
)}
</>
);
};

View file

@ -0,0 +1,59 @@
// @flow
import 'scss/component/_comment-selectors.scss';
import Button from 'component/button';
import React from 'react';
import FilePrice from 'component/filePrice';
import OptimizedImage from 'component/optimizedImage';
import ChannelThumbnail from 'component/channelThumbnail';
import UriIndicator from 'component/uriIndicator';
type Props = {
activeChannelUrl: string,
src: string,
price: number,
exchangeRate?: number,
};
export const StickerReviewBox = (props: Props) => {
const { activeChannelUrl, src, price, exchangeRate } = props;
return (
<div className="commentCreate__stickerPreview">
<div className="commentCreate__stickerPreviewInfo">
<ChannelThumbnail xsmall uri={activeChannelUrl} />
<UriIndicator uri={activeChannelUrl} link />
</div>
<div className="commentCreate__stickerPreviewImage">
<OptimizedImage src={src} waitLoad loading="lazy" />
</div>
{Boolean(price && exchangeRate) && (
<FilePrice
customPrices={{
priceFiat: price,
priceLBC: Number(exchangeRate) !== 0 ? price / Number(exchangeRate) : 0,
}}
isFiat
/>
)}
</div>
);
};
type StickerButtonProps = {
isReviewingStickerComment: boolean,
};
export const StickerActionButton = (stickerButtonProps: StickerButtonProps) => {
const { isReviewingStickerComment, ...buttonProps } = stickerButtonProps;
return (
<Button
{...buttonProps}
title={__('Stickers')}
label={isReviewingStickerComment ? __('Different Sticker') : undefined}
/>
);
};

View file

@ -1,102 +0,0 @@
// @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 StickerWrapper = (stickerProps: any) => {
const { price, children } = stickerProps;
return price ? <div className="stickerItem--paid">{children}</div> : children;
};
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)}
>
<StickerWrapper price={sticker.price}>
<OptimizedImage src={sticker.url} waitLoad loading="lazy" />
{sticker.price && sticker.price > 0 && (
<CreditAmount superChatLight amount={sticker.price} size={2} isFiat />
)}
</StickerWrapper>
</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

@ -0,0 +1,62 @@
// @flow
import 'scss/component/_comment-selectors.scss';
import Button from 'component/button';
import React from 'react';
import ChannelThumbnail from 'component/channelThumbnail';
import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount';
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
type Props = {
activeChannelUrl: string,
tipAmount: number,
activeTab: string,
message: string,
};
export const TipReviewBox = (props: Props) => {
const { activeChannelUrl, tipAmount, activeTab, message } = props;
return (
<div className="commentCreate__supportCommentPreview">
<CreditAmount
amount={tipAmount}
className="commentCreate__supportCommentPreviewAmount"
isFiat={activeTab === TAB_FIAT}
size={activeTab === TAB_LBC ? 18 : 2}
/>
<ChannelThumbnail xsmall uri={activeChannelUrl} />
<div className="commentCreate__supportCommentBody">
<UriIndicator uri={activeChannelUrl} link />
<div>{message}</div>
</div>
</div>
);
};
type TipButtonProps = {
name: string,
tab: string,
activeTab: string,
tipSelectorOpen: boolean,
onClick: (tab: string) => void,
};
export const TipActionButton = (tipButtonProps: TipButtonProps) => {
const { name, tab, activeTab, tipSelectorOpen, onClick, ...buttonProps } = tipButtonProps;
return (
(!tipSelectorOpen || activeTab !== tab) && (
<Button
{...buttonProps}
title={name}
label={tipSelectorOpen ? __('Switch to %tip_method%', { tip_method: name }) : undefined}
onClick={() => onClick(tab)}
/>
)
);
};

View file

@ -14,23 +14,17 @@ import * as KEYCODES from 'constants/keycodes';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types'; import * as MODALS from 'constants/modal_types';
import Button from 'component/button'; import Button from 'component/button';
import ChannelThumbnail from 'component/channelThumbnail';
import classnames from 'classnames'; import classnames from 'classnames';
import CreditAmount from 'component/common/credit-amount'; import CommentSelectors from './comment-selectors';
import EmoteSelector from './emote-selector';
import Empty from 'component/common/empty'; import Empty from 'component/common/empty';
import FilePrice from 'component/filePrice';
import I18nMessage from 'component/i18nMessage';
import Icon from 'component/common/icon';
import OptimizedImage from 'component/optimizedImage';
import React from 'react'; import React from 'react';
import SelectChannel from 'component/selectChannel';
import StickerSelector from './sticker-selector';
import type { ElementRef } from 'react'; import type { ElementRef } from 'react';
import UriIndicator from 'component/uriIndicator';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import WalletTipAmountSelector from 'component/walletTipAmountSelector'; import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { useIsMobile } from 'effects/use-screensize'; 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'; import { getStripeEnvironment } from 'util/stripe';
const stripeEnvironment = getStripeEnvironment(); const stripeEnvironment = getStripeEnvironment();
@ -59,6 +53,7 @@ type Props = {
supportDisabled: boolean, supportDisabled: boolean,
uri: string, uri: string,
disableInput?: boolean, disableInput?: boolean,
onSlimInputClick?: () => void,
createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>, createComment: (string, string, string, ?string, ?string, ?string, boolean) => Promise<any>,
doFetchCreatorSettings: (channelId: string) => Promise<any>, doFetchCreatorSettings: (channelId: string) => Promise<any>,
doToast: ({ message: string }) => void, doToast: ({ message: string }) => void,
@ -90,6 +85,7 @@ export function CommentCreate(props: Props) {
supportDisabled, supportDisabled,
uri, uri,
disableInput, disableInput,
onSlimInputClick,
createComment, createComment,
doFetchCreatorSettings, doFetchCreatorSettings,
doToast, doToast,
@ -115,7 +111,7 @@ export function CommentCreate(props: Props) {
const [isSubmitting, setSubmitting] = React.useState(false); const [isSubmitting, setSubmitting] = React.useState(false);
const [commentFailure, setCommentFailure] = React.useState(false); const [commentFailure, setCommentFailure] = React.useState(false);
const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined }); const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined });
const [isSupportComment, setIsSupportComment] = React.useState(); const [tipSelectorOpen, setTipSelector] = React.useState();
const [isReviewingSupportComment, setReviewingSupportComment] = React.useState(); const [isReviewingSupportComment, setReviewingSupportComment] = React.useState();
const [isReviewingStickerComment, setReviewingStickerComment] = React.useState(); const [isReviewingStickerComment, setReviewingStickerComment] = React.useState();
const [selectedSticker, setSelectedSticker] = React.useState(); const [selectedSticker, setSelectedSticker] = React.useState();
@ -123,18 +119,19 @@ export function CommentCreate(props: Props) {
const [convertedAmount, setConvertedAmount] = React.useState(); const [convertedAmount, setConvertedAmount] = React.useState();
const [commentValue, setCommentValue] = React.useState(''); const [commentValue, setCommentValue] = React.useState('');
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const [stickerSelector, setStickerSelector] = React.useState();
const [activeTab, setActiveTab] = React.useState(); const [activeTab, setActiveTab] = React.useState();
const [tipError, setTipError] = React.useState(); const [tipError, setTipError] = React.useState();
const [deletedComment, setDeletedComment] = React.useState(false); const [deletedComment, setDeletedComment] = React.useState(false);
const [showEmotes, setShowEmotes] = React.useState(false); const [showSelectors, setShowSelectors] = React.useState(false);
const [disableReviewButton, setDisableReviewButton] = React.useState(); const [disableReviewButton, setDisableReviewButton] = React.useState();
const [exchangeRate, setExchangeRate] = React.useState(); const [exchangeRate, setExchangeRate] = React.useState();
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(undefined); const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(undefined);
const [tipModalOpen, setTipModalOpen] = React.useState(undefined);
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const charCount = commentValue ? commentValue.length : 0; const charCount = commentValue ? commentValue.length : 0;
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || disableInput; const hasNothingToSumbit = !commentValue.length && !selectedSticker;
const disabled = deletedComment || isSubmitting || isFetchingChannels || hasNothingToSumbit || disableInput;
const channelId = getChannelIdFromClaim(claim); const channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
@ -142,6 +139,7 @@ export function CommentCreate(props: Props) {
const minAmount = minTip || minSuper || 0; const minAmount = minTip || minSuper || 0;
const minAmountMet = minAmount === 0 || tipAmount >= minAmount; const minAmountMet = minAmount === 0 || tipAmount >= minAmount;
const stickerPrice = selectedSticker && selectedSticker.price; const stickerPrice = selectedSticker && selectedSticker.price;
const tipSelectorError = tipError || disableReviewButton;
const minAmountRef = React.useRef(minAmount); const minAmountRef = React.useRef(minAmount);
minAmountRef.current = minAmount; minAmountRef.current = minAmount;
@ -150,16 +148,52 @@ export function CommentCreate(props: Props) {
// Functions // Functions
// ************************************************************************** // **************************************************************************
function addEmoteToComment(emote: string) {
setCommentValue(
commentValue + (commentValue && commentValue.charAt(commentValue.length - 1) !== ' ' ? ` ${emote} ` : `${emote} `)
);
const inputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input && formFieldRef.current.input;
if (inputRef && inputRef.current) inputRef.current.focus();
}
function handleSelectTipComment(tab: string) {
setActiveTab(tab);
setTipSelector(true);
}
function handleStickerComment() {
if (selectedSticker) setReviewingStickerComment(false);
setTipSelector(false);
setShowSelectors(!showSelectors);
}
function handleSelectSticker(sticker: any) { function handleSelectSticker(sticker: any) {
// $FlowFixMe // $FlowFixMe
setSelectedSticker(sticker); setSelectedSticker(sticker);
setReviewingStickerComment(true); setReviewingStickerComment(true);
setTipAmount(sticker.price || 0); setTipAmount(sticker.price || 0);
setStickerSelector(false); setShowSelectors(false);
if (sticker.price && sticker.price > 0) { if (sticker.price && sticker.price > 0) {
setActiveTab(canReceiveFiatTip ? TAB_FIAT : TAB_LBC); setActiveTab(canReceiveFiatTip ? TAB_FIAT : TAB_LBC);
setIsSupportComment(true); setTipSelector(true);
}
}
function handleCancelSticker() {
setReviewingStickerComment(false);
setSelectedSticker(null);
}
function handleCancelSupport() {
if (!isReviewingSupportComment) setTipSelector(false);
setReviewingSupportComment(false);
if (stickerPrice) {
setReviewingStickerComment(false);
setShowSelectors(false);
setSelectedSticker(null);
} }
} }
@ -250,7 +284,7 @@ export function CommentCreate(props: Props) {
setCommentValue(''); setCommentValue('');
setReviewingSupportComment(false); setReviewingSupportComment(false);
setIsSupportComment(false); setTipSelector(false);
setCommentFailure(false); setCommentFailure(false);
setSubmitting(false); setSubmitting(false);
}); });
@ -266,7 +300,6 @@ export function CommentCreate(props: Props) {
function handleCreateComment(txid, payment_intent_id, environment) { function handleCreateComment(txid, payment_intent_id, environment) {
if (isSubmitting || disableInput) return; if (isSubmitting || disableInput) return;
setShowEmotes(false);
setSubmitting(true); setSubmitting(true);
const stickerValue = selectedSticker && buildValidSticker(selectedSticker.name); const stickerValue = selectedSticker && buildValidSticker(selectedSticker.name);
@ -279,7 +312,7 @@ export function CommentCreate(props: Props) {
if (res && res.signature) { if (res && res.signature) {
if (!stickerValue) setCommentValue(''); if (!stickerValue) setCommentValue('');
setReviewingSupportComment(false); setReviewingSupportComment(false);
setIsSupportComment(false); setTipSelector(false);
setCommentFailure(false); setCommentFailure(false);
if (onDoneReplying) { if (onDoneReplying) {
@ -299,6 +332,19 @@ export function CommentCreate(props: Props) {
}); });
} }
function handleSubmitSticker() {
if (isReviewingSupportComment) {
handleSupportComment();
} else {
handleCreateComment();
}
setSelectedSticker(null);
setReviewingStickerComment(false);
setShowSelectors(false);
setTipSelector(false);
}
// ************************************************************************** // **************************************************************************
// Effects // Effects
// ************************************************************************** // **************************************************************************
@ -327,7 +373,7 @@ export function CommentCreate(props: Props) {
// Stickers: Check if creator has a tip account saved (on selector so that if a paid sticker is selected, // 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) // it defaults to LBC tip instead of USD)
React.useEffect(() => { React.useEffect(() => {
if (!stripeEnvironment || !stickerSelector || canReceiveFiatTip !== undefined) return; if (!stripeEnvironment || !showSelectors || canReceiveFiatTip !== undefined) return;
const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id; const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id;
const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name; const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name;
@ -350,7 +396,7 @@ export function CommentCreate(props: Props) {
} }
}) })
.catch(() => {}); .catch(() => {});
}, [canReceiveFiatTip, claim.claim_id, claim.name, claim.signing_channel, stickerSelector]); }, [canReceiveFiatTip, claim.claim_id, claim.name, claim.signing_channel, showSelectors]);
// Handle keyboard shortcut comment creation // Handle keyboard shortcut comment creation
React.useEffect(() => { React.useEffect(() => {
@ -384,14 +430,6 @@ export function CommentCreate(props: Props) {
// Render // Render
// ************************************************************************** // **************************************************************************
const getActionButton = (
title: string,
label?: string,
icon: string,
handleClick: () => void,
disabled?: boolean
) => <Button title={title} label={label} button="alt" icon={icon} onClick={handleClick} disabled={disabled} />;
if (channelSettings && !channelSettings.comments_enabled) { if (channelSettings && !channelSettings.comments_enabled) {
return <Empty padded text={__('This channel has disabled comments on their page.')} />; return <Empty padded text={__('This channel has disabled comments on their page.')} />;
} }
@ -400,6 +438,7 @@ export function CommentCreate(props: Props) {
return ( return (
<div <div
role="button" role="button"
className="comment-create__auth"
onClick={() => { onClick={() => {
if (embed) { if (embed) {
window.open(`https://odysee.com/$/${PAGES.AUTH}?redirect=/$/${PAGES.LIVESTREAM}`); window.open(`https://odysee.com/$/${PAGES.AUTH}?redirect=/$/${PAGES.LIVESTREAM}`);
@ -422,6 +461,18 @@ export function CommentCreate(props: Props) {
); );
} }
const commentSelectorsProps = { claimIsMine, addEmoteToComment, handleSelectSticker };
const submitButtonProps = { button: 'primary', type: 'submit', requiresAuth: true };
const actionButtonProps = { button: 'alt', isReviewingStickerComment };
const tipButtonProps = {
...actionButtonProps,
disabled: !commentValue.length && !selectedSticker,
tipSelectorOpen,
activeTab,
onClick: handleSelectTipComment,
};
const cancelButtonProps = { button: 'link', label: __('Cancel') };
return ( return (
<Form <Form
onSubmit={() => {}} onSubmit={() => {}}
@ -431,50 +482,29 @@ export function CommentCreate(props: Props) {
'commentCreate--bottom': bottom, 'commentCreate--bottom': bottom,
})} })}
> >
{/* Input Box/Preview Box */} {selectedSticker ? (
{stickerSelector ? ( activeChannelClaim && (
<StickerSelector onSelect={(sticker) => handleSelectSticker(sticker)} claimIsMine={claimIsMine} /> <StickerReviewBox
) : isReviewingStickerComment && activeChannelClaim && selectedSticker ? ( activeChannelUrl={activeChannelClaim.canonical_url}
<div className="commentCreate__stickerPreview"> src={selectedSticker.url}
<div className="commentCreate__stickerPreviewInfo"> price={selectedSticker.price || 0}
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} /> exchangeRate={exchangeRate}
<UriIndicator uri={activeChannelClaim.canonical_url} link />
</div>
<div className="commentCreate__stickerPreviewImage">
<OptimizedImage src={selectedSticker && selectedSticker.url} waitLoad loading="lazy" />
</div>
{selectedSticker.price && exchangeRate && (
<FilePrice
customPrices={{ priceFiat: selectedSticker.price, priceLBC: selectedSticker.price / exchangeRate }}
isFiat
/> />
)} )
</div> ) : isReviewingSupportComment ? (
) : isReviewingSupportComment && activeChannelClaim ? ( activeChannelClaim &&
<div className="commentCreate__supportCommentPreview"> activeTab && (
<CreditAmount <TipReviewBox
amount={tipAmount} activeChannelUrl={activeChannelClaim.canonical_url}
className="commentCreate__supportCommentPreviewAmount" tipAmount={tipAmount}
isFiat={activeTab === TAB_FIAT} activeTab={activeTab}
size={activeTab === TAB_LBC ? 18 : 2} message={commentValue}
/> />
)
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<div className="commentCreate__supportCommentBody">
<UriIndicator uri={activeChannelClaim.canonical_url} link />
<div>{commentValue}</div>
</div>
</div>
) : ( ) : (
<> <>
{showEmotes && ( {!isMobile && showSelectors && (
<EmoteSelector <CommentSelectors {...commentSelectorsProps} closeSelector={() => setShowSelectors(false)} />
commentValue={commentValue}
setCommentValue={setCommentValue}
closeSelector={() => setShowEmotes(false)}
/>
)} )}
<FormField <FormField
@ -483,30 +513,31 @@ export function CommentCreate(props: Props) {
className={isReply ? 'create__reply' : 'create__comment'} className={isReply ? 'create__reply' : 'create__comment'}
disabled={isFetchingChannels || disableInput} disabled={isFetchingChannels || disableInput}
isLivestream={isLivestream} isLivestream={isLivestream}
label={ label={<FormChannelSelector isReply={Boolean(isReply)} isLivestream={Boolean(isLivestream)} />}
<div className="comment-create__label-wrapper">
<span className="comment-create__label">
{(isReply ? __('Replying as') : isLivestream ? __('Chat as') : __('Comment as')) + ' '}
</span>
<SelectChannel tiny />
</div>
}
name={isReply ? 'create__reply' : 'create__comment'} name={isReply ? 'create__reply' : 'create__comment'}
onChange={(e) => setCommentValue(SIMPLE_SITE || !advancedEditor || isReply ? e.target.value : e)} onChange={(e) => setCommentValue(SIMPLE_SITE || !advancedEditor || isReply ? e.target.value : e)}
openEmoteMenu={() => setShowEmotes(!showEmotes)} handleTip={(isLBC) => {
handleTip={(isLBC) => setActiveTab(isLBC ? TAB_LBC : TAB_FIAT);
setTipModalOpen(true);
doOpenModal(MODALS.SEND_TIP, { doOpenModal(MODALS.SEND_TIP, {
uri, uri,
isTipOnly: true, isTipOnly: true,
hasSelectedTab: isLBC ? TAB_LBC : TAB_FIAT, hasSelectedTab: isLBC ? TAB_LBC : TAB_FIAT,
customText: __('Preview Comment Tip'),
setAmount: (amount) => { setAmount: (amount) => {
setTipAmount(amount); setTipAmount(amount);
setReviewingSupportComment(true); setReviewingSupportComment(true);
}, },
}) });
} }}
handleSubmit={handleCreateComment} handleSubmit={handleCreateComment}
noEmojis={isMobile} slimInput={isMobile}
onSlimInputClick={onSlimInputClick}
commentSelectorsProps={commentSelectorsProps}
submitButtonRef={buttonRef}
setShowSelectors={setShowSelectors}
showSelectors={showSelectors}
tipModalOpen={tipModalOpen}
placeholder={__('Say something about this...')} placeholder={__('Say something about this...')}
quickActionHandler={!SIMPLE_SITE ? () => setAdvancedEditor(!advancedEditor) : undefined} quickActionHandler={!SIMPLE_SITE ? () => setAdvancedEditor(!advancedEditor) : undefined}
quickActionLabel={ quickActionLabel={
@ -521,7 +552,7 @@ export function CommentCreate(props: Props) {
</> </>
)} )}
{!isMobile && (isSupportComment || (isReviewingStickerComment && stickerPrice)) && ( {(!isMobile || isReviewingStickerComment) && (tipSelectorOpen || (isReviewingStickerComment && stickerPrice)) && (
<WalletTipAmountSelector <WalletTipAmountSelector
activeTab={activeTab} activeTab={activeTab}
amount={tipAmount} amount={tipAmount}
@ -538,14 +569,13 @@ export function CommentCreate(props: Props) {
/> />
)} )}
{/* Bottom Action Buttons */} {(!isMobile || !isLivestream || isReviewingStickerComment || isReviewingSupportComment) && (
{!isMobile && ( <div className="section__actions">
<div className="section__actions section__actions--no-margin">
{/* Submit Button */} {/* Submit Button */}
{isReviewingSupportComment ? ( {isReviewingSupportComment ? (
<Button <Button
{...submitButtonProps}
autoFocus autoFocus
button="primary"
disabled={disabled || !minAmountMet} disabled={disabled || !minAmountMet}
label={ label={
isSubmitting isSubmitting
@ -556,40 +586,21 @@ export function CommentCreate(props: Props) {
} }
onClick={handleSupportComment} onClick={handleSupportComment}
/> />
) : isReviewingStickerComment && selectedSticker ? ( ) : tipSelectorOpen ? (
<Button <Button
button="primary" {...submitButtonProps}
label={__('Send')} disabled={disabled || tipSelectorError || !minAmountMet}
disabled={(isSupportComment && (tipError || disableReviewButton)) || disableInput}
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} icon={activeTab === TAB_LBC ? ICONS.LBC : ICONS.FINANCE}
label={__('Review')} label={__('Review')}
onClick={() => setReviewingSupportComment(true)} onClick={() => setReviewingSupportComment(true)}
requiresAuth
/> />
) : ( ) : (
(!isMobile || selectedSticker) &&
(!minTip || claimIsMine) && ( (!minTip || claimIsMine) && (
<Button <Button
{...submitButtonProps}
ref={buttonRef} ref={buttonRef}
button="primary" disabled={disabled}
disabled={disabled || stickerSelector}
type="submit"
label={ label={
isReply isReply
? isSubmitting ? isSubmitting
@ -599,135 +610,36 @@ export function CommentCreate(props: Props) {
? __('Commenting...') ? __('Commenting...')
: __('Comment --[button to submit something]--') : __('Comment --[button to submit something]--')
} }
requiresAuth onClick={() => (selectedSticker ? handleSubmitSticker() : handleCreateComment())}
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
/> />
) )
)} )}
{/** Stickers/Support Buttons **/} {!isMobile && (
{!supportDisabled && !stickerSelector && (
<> <>
{getActionButton( <StickerActionButton {...actionButtonProps} icon={ICONS.STICKER} onClick={handleStickerComment} />
__('Stickers'),
isReviewingStickerComment ? __('Different Sticker') : undefined,
ICONS.STICKER,
() => {
if (isReviewingStickerComment) setReviewingStickerComment(false);
setIsSupportComment(false);
setStickerSelector(true);
}
)}
{!claimIsMine && ( {!supportDisabled && (
<> <>
{(!isSupportComment || activeTab !== TAB_LBC) && <TipActionButton {...tipButtonProps} name={__('Credits')} icon={ICONS.LBC} tab={TAB_LBC} />
getActionButton(
__('Credits'),
isSupportComment ? __('Switch to Credits') : undefined,
ICONS.LBC,
() => {
setActiveTab(TAB_LBC);
if (isMobile) { {stripeEnvironment && (
doOpenModal(MODALS.SEND_TIP, { <TipActionButton {...tipButtonProps} name={__('Cash')} icon={ICONS.FINANCE} tab={TAB_FIAT} />
uri,
isTipOnly: true,
hasSelectedTab: TAB_LBC,
setAmount: (amount) => {
setTipAmount(amount);
setReviewingSupportComment(true);
},
});
} else {
setIsSupportComment(true);
}
},
!commentValue.length
)}
{stripeEnvironment &&
(!isSupportComment || activeTab !== TAB_FIAT) &&
getActionButton(
__('Cash'),
isSupportComment ? __('Switch to Cash') : undefined,
ICONS.FINANCE,
() => {
setActiveTab(TAB_FIAT);
if (isMobile) {
doOpenModal(MODALS.SEND_TIP, {
uri,
isTipOnly: true,
hasSelectedTab: TAB_FIAT,
setAmount: (amount) => {
setTipAmount(amount);
setReviewingSupportComment(true);
},
});
} else {
setIsSupportComment(true);
}
},
!commentValue.length
)} )}
</> </>
)} )}
</> </>
)} )}
{/* Cancel Button */} {tipSelectorOpen || isReviewingSupportComment ? (
{(isSupportComment || <Button {...cancelButtonProps} disabled={isSubmitting} onClick={handleCancelSupport} />
isReviewingSupportComment || ) : isReviewingStickerComment ? (
stickerSelector || <Button {...cancelButtonProps} onClick={handleCancelSticker} />
isReviewingStickerComment || ) : (
(isReply && !minTip)) && ( onCancelReplying && <Button {...cancelButtonProps} onClick={onCancelReplying} />
<Button
disabled={isSupportComment && isSubmitting}
button="link"
label={__('Cancel')}
onClick={() => {
if (isSupportComment || isReviewingSupportComment) {
if (!isReviewingSupportComment) setIsSupportComment(false);
setReviewingSupportComment(false);
if (stickerPrice) {
setReviewingStickerComment(false);
setStickerSelector(false);
setSelectedSticker(null);
}
} else if (stickerSelector || isReviewingStickerComment) {
setReviewingStickerComment(false);
setStickerSelector(false);
setSelectedSticker(null);
} else if (isReply && !minTip && onCancelReplying) {
onCancelReplying();
}
}}
/>
)} )}
{/* Help Text */} <HelpText deletedComment={deletedComment} minAmount={minAmount} minSuper={minSuper} minTip={minTip} />
{deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>}
{!!minAmount && (
<div className="help--notice commentCreate__minAmountNotice">
<I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}>
{minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
</I18nMessage>
<Icon
customTooltipText={
minTip
? __('This channel requires a minimum tip for each comment.')
: minSuper
? __('This channel requires a minimum amount for HyperChats to be visible.')
: ''
}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</div>
)}
</div> </div>
)} )}
</Form> </Form>

View file

@ -9,6 +9,7 @@ import * as MODALS from 'constants/modal_types';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import React from 'react'; import React from 'react';
import { useIsMobile } from 'effects/use-screensize';
type Props = { type Props = {
uri: ?string, uri: ?string,
@ -67,6 +68,8 @@ function CommentMenuList(props: Props) {
handleDismissPin, handleDismissPin,
} = props; } = props;
const isMobile = useIsMobile();
const { const {
location: { pathname, search }, location: { pathname, search },
} = useHistory(); } = useHistory();
@ -253,7 +256,7 @@ function CommentMenuList(props: Props) {
</MenuItem> </MenuItem>
)} )}
{isPinned && isLiveComment && ( {isPinned && isLiveComment && isMobile && (
<MenuItem className="comment__menu-option menu__link" onSelect={handleDismissPin}> <MenuItem className="comment__menu-option menu__link" onSelect={handleDismissPin}>
<Icon aria-hidden icon={ICONS.DISMISS_ALL} /> <Icon aria-hidden icon={ICONS.DISMISS_ALL} />
{__('Dismiss Pin')} {__('Dismiss Pin')}

View file

@ -269,37 +269,17 @@ function CommentList(props: Props) {
/> />
)); ));
const sortButton = (label, icon, sortOption) => ( const actionButtonsProps = { totalComments, sort, changeSort, setPage };
<Button
button="alt"
label={label}
icon={icon}
iconSize={18}
onClick={() => changeSort(sortOption)}
className={classnames(`button-toggle`, {
'button-toggle--active': sort === sortOption,
})}
/>
);
return ( return (
<Card <Card
className="card--enable-overflow" className="card--enable-overflow"
title={!isMobile && title} title={!isMobile && title}
titleActions={ titleActions={<CommentActionButtons {...actionButtonsProps} />}
<>
{totalComments > 1 && ENABLE_COMMENT_REACTIONS && (
<span className="comment__sort">
{sortButton(__('Best'), ICONS.BEST, SORT_BY.POPULARITY)}
{sortButton(__('Controversial'), ICONS.CONTROVERSIAL, SORT_BY.CONTROVERSY)}
{sortButton(__('New'), ICONS.NEW, SORT_BY.NEWEST)}
</span>
)}
<Button button="alt" icon={ICONS.REFRESH} title={__('Refresh')} onClick={() => setPage(0)} />
</>
}
actions={ actions={
<> <>
{isMobile && <CommentActionButtons {...actionButtonsProps} />}
<CommentCreate uri={uri} /> <CommentCreate uri={uri} />
{channelSettings && channelSettings.comments_enabled && !isFetchingComments && !totalComments && ( {channelSettings && channelSettings.comments_enabled && !isFetchingComments && !totalComments && (
@ -349,3 +329,55 @@ function CommentList(props: Props) {
} }
export default CommentList; export default CommentList;
type ActionButtonsProps = {
totalComments: number,
sort: string,
changeSort: (string) => void,
setPage: (number) => void,
};
const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => {
const { totalComments, sort, changeSort, setPage } = actionButtonsProps;
const sortButtonProps = { activeSort: sort, changeSort };
return (
<>
{totalComments > 1 && ENABLE_COMMENT_REACTIONS && (
<span className="comment__sort">
<SortButton {...sortButtonProps} label={__('Best')} icon={ICONS.BEST} sortOption={SORT_BY.POPULARITY} />
<SortButton
{...sortButtonProps}
label={__('Controversial')}
icon={ICONS.CONTROVERSIAL}
sortOption={SORT_BY.CONTROVERSY}
/>
<SortButton {...sortButtonProps} label={__('New')} icon={ICONS.NEW} sortOption={SORT_BY.NEWEST} />
</span>
)}
<Button button="alt" icon={ICONS.REFRESH} title={__('Refresh')} onClick={() => setPage(0)} />
</>
);
};
type SortButtonProps = {
activeSort: string,
sortOption: string,
changeSort: (string) => void,
};
const SortButton = (sortButtonProps: SortButtonProps) => {
const { activeSort, sortOption, changeSort, ...buttonProps } = sortButtonProps;
return (
<Button
{...buttonProps}
className={classnames(`button-toggle`, { 'button-toggle--active': activeSort === sortOption })}
button="alt"
iconSize={18}
onClick={() => changeSort(sortOption)}
/>
);
};

View file

@ -3,13 +3,14 @@ import 'easymde/dist/easymde.min.css';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field'; import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import { openEditorMenu, stopContextMenu } from 'util/context-menu'; import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import * as ICONS from 'constants/icons';
import Button from 'component/button'; import Button from 'component/button';
import MarkdownPreview from 'component/common/markdown-preview'; import MarkdownPreview from 'component/common/markdown-preview';
import React from 'react'; import React from 'react';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
import SimpleMDE from 'react-simplemde-editor'; import SimpleMDE from 'react-simplemde-editor';
import type { ElementRef, Node } from 'react'; import type { ElementRef, Node } from 'react';
import Drawer from '@mui/material/Drawer';
import CommentSelectors from 'component/commentCreate/comment-selectors';
// prettier-ignore // prettier-ignore
const TextareaWithSuggestions = lazyImport(() => import('component/textareaWithSuggestions' /* webpackChunkName: "suggestions" */)); const TextareaWithSuggestions = lazyImport(() => import('component/textareaWithSuggestions' /* webpackChunkName: "suggestions" */));
@ -33,7 +34,6 @@ type Props = {
max?: number, max?: number,
min?: number, min?: number,
name: string, name: string,
noEmojis?: boolean,
placeholder?: string | number, placeholder?: string | number,
postfix?: string, postfix?: string,
prefix?: string, prefix?: string,
@ -44,15 +44,25 @@ type Props = {
textAreaMaxLength?: number, textAreaMaxLength?: number,
type?: string, type?: string,
value?: string | number, value?: string | number,
slimInput?: boolean,
commentSelectorsProps?: any,
showSelectors?: boolean,
submitButtonRef?: any,
tipModalOpen?: boolean,
onSlimInputClick?: () => void,
onChange?: (any) => any, onChange?: (any) => any,
openEmoteMenu?: () => void, setShowSelectors?: (boolean) => void,
quickActionHandler?: (any) => any, quickActionHandler?: (any) => any,
render?: () => React$Node, render?: () => React$Node,
handleTip?: (isLBC: boolean) => any, handleTip?: (isLBC: boolean) => any,
handleSubmit?: () => any, handleSubmit?: () => any,
}; };
export class FormField extends React.PureComponent<Props> { type State = {
drawerOpen: boolean,
};
export class FormField extends React.PureComponent<Props, State> {
static defaultProps = { labelOnLeft: false, blockWrap: true }; static defaultProps = { labelOnLeft: false, blockWrap: true };
input: { current: ElementRef<any> }; input: { current: ElementRef<any> };
@ -60,6 +70,10 @@ export class FormField extends React.PureComponent<Props> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.input = React.createRef(); this.input = React.createRef();
this.state = {
drawerOpen: false,
};
} }
componentDidMount() { componentDidMount() {
@ -69,6 +83,14 @@ export class FormField extends React.PureComponent<Props> {
if (input && autoFocus) input.focus(); if (input && autoFocus) input.focus();
} }
componentDidUpdate() {
const { showSelectors, slimInput } = this.props;
const input = this.input.current;
// Opened selectors (emoji/sticker) -> blur input and hide keyboard
if (slimInput && showSelectors && input) input.blur();
}
render() { render() {
const { const {
uri, uri,
@ -85,15 +107,20 @@ export class FormField extends React.PureComponent<Props> {
label, label,
labelOnLeft, labelOnLeft,
name, name,
noEmojis,
postfix, postfix,
prefix, prefix,
quickActionLabel, quickActionLabel,
stretch, stretch,
textAreaMaxLength, textAreaMaxLength,
type, type,
openEmoteMenu, slimInput,
commentSelectorsProps,
showSelectors,
submitButtonRef,
tipModalOpen,
onSlimInputClick,
quickActionHandler, quickActionHandler,
setShowSelectors,
render, render,
handleTip, handleTip,
handleSubmit, handleSubmit,
@ -240,9 +267,20 @@ export class FormField extends React.PureComponent<Props> {
case 'textarea': case 'textarea':
return ( return (
<fieldset-section> <fieldset-section>
{(label || quickAction) && ( <TextareaWrapper
isDrawerOpen={Boolean(this.state.drawerOpen)}
toggleDrawer={() => this.setState({ drawerOpen: !this.state.drawerOpen })}
closeSelector={setShowSelectors ? () => setShowSelectors(false) : () => {}}
commentSelectorsProps={commentSelectorsProps}
showSelectors={Boolean(showSelectors)}
slimInput={slimInput}
tipModalOpen={tipModalOpen}
onSlimInputClick={onSlimInputClick}
>
{(!slimInput || this.state.drawerOpen) && (label || quickAction) && (
<div className="form-field__two-column"> <div className="form-field__two-column">
<label htmlFor={name}>{label}</label> <label htmlFor={name}>{label}</label>
{quickAction}
{countInfo} {countInfo}
</div> </div>
)} )}
@ -264,26 +302,23 @@ export class FormField extends React.PureComponent<Props> {
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT} maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
inputRef={this.input} inputRef={this.input}
isLivestream={isLivestream} isLivestream={isLivestream}
handleEmojis={openEmoteMenu} toggleSelectors={setShowSelectors ? () => setShowSelectors(!showSelectors) : undefined}
handleTip={handleTip} handleTip={handleTip}
handleSubmit={handleSubmit} handleSubmit={() => {
if (handleSubmit) handleSubmit();
if (slimInput) this.setState({ drawerOpen: false });
}}
claimIsMine={commentSelectorsProps && commentSelectorsProps.claimIsMine}
{...inputProps} {...inputProps}
handlePreventClick={
!this.state.drawerOpen ? () => this.setState({ drawerOpen: true }) : undefined
}
autoFocus={this.state.drawerOpen}
submitButtonRef={submitButtonRef}
/> />
</React.Suspense> </React.Suspense>
)} )}
</TextareaWrapper>
{!noEmojis && openEmoteMenu && (
<div className="form-field__textarea-info">
<Button
type="alt"
className="button--file-action"
title="Emotes"
onClick={openEmoteMenu}
icon={ICONS.EMOJI}
iconSize={20}
/>
</div>
)}
</fieldset-section> </fieldset-section>
); );
default: default:
@ -321,3 +356,65 @@ export class FormField extends React.PureComponent<Props> {
} }
export default FormField; export default FormField;
type TextareaWrapperProps = {
slimInput?: boolean,
children: Node,
isDrawerOpen: boolean,
showSelectors?: boolean,
commentSelectorsProps?: any,
tipModalOpen?: boolean,
onSlimInputClick?: () => void,
toggleDrawer: () => void,
closeSelector?: () => void,
};
function TextareaWrapper(wrapperProps: TextareaWrapperProps) {
const {
children,
slimInput,
isDrawerOpen,
commentSelectorsProps,
showSelectors,
tipModalOpen,
onSlimInputClick,
toggleDrawer,
closeSelector,
} = wrapperProps;
function handleCloseAll() {
toggleDrawer();
if (closeSelector) closeSelector();
if (onSlimInputClick) onSlimInputClick();
}
return slimInput ? (
!isDrawerOpen ? (
<div
role="button"
onClick={() => {
toggleDrawer();
if (onSlimInputClick) onSlimInputClick();
}}
>
{children}
</div>
) : (
<Drawer
className="comment-create--drawer"
anchor="bottom"
open
onClose={handleCloseAll}
// The Modal tries to enforce focus when open and doesn't allow clicking or changing any
// other input boxes, so in this case it is disabled when trying to type in a custom tip
ModalProps={{ disableEnforceFocus: tipModalOpen }}
>
{children}
{showSelectors && <CommentSelectors closeSelector={closeSelector} {...commentSelectorsProps} />}
</Drawer>
)
) : (
<>{children}</>
);
}

View file

@ -6,7 +6,6 @@ import { Global } from '@emotion/react';
// $FlowFixMe // $FlowFixMe
import { grey } from '@mui/material/colors'; import { grey } from '@mui/material/colors';
import { formatLbryUrlForWeb } from 'util/url';
import { useIsMobile } from 'effects/use-screensize'; import { useIsMobile } from 'effects/use-screensize';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import Button from 'component/button'; import Button from 'component/button';
@ -42,6 +41,7 @@ type Props = {
superchatsHidden?: boolean, superchatsHidden?: boolean,
customViewMode?: string, customViewMode?: string,
theme: string, theme: string,
setCustomViewMode?: (any) => void,
doCommentList: (string, string, number, number) => void, doCommentList: (string, string, number, number) => void,
doResolveUris: (Array<string>, boolean) => void, doResolveUris: (Array<string>, boolean) => void,
doSuperChatList: (string) => void, doSuperChatList: (string) => void,
@ -60,6 +60,7 @@ export default function LivestreamChatLayout(props: Props) {
superchatsHidden, superchatsHidden,
customViewMode, customViewMode,
theme, theme,
setCustomViewMode,
doCommentList, doCommentList,
doResolveUris, doResolveUris,
doSuperChatList, doSuperChatList,
@ -67,11 +68,18 @@ export default function LivestreamChatLayout(props: Props) {
const isMobile = useIsMobile() && !isPopoutWindow; const isMobile = useIsMobile() && !isPopoutWindow;
const discussionElement = document.querySelector('.livestream__comments'); const webElement = document.querySelector('.livestream__comments');
const mobileElement = document.querySelector('.livestream__comments--mobile');
const discussionElement = isMobile ? mobileElement : webElement;
const allCommentsElem = document.querySelectorAll('.livestream__comment');
const lastCommentElem = allCommentsElem && allCommentsElem[allCommentsElem.length - 1];
const minScrollPos =
discussionElement && lastCommentElem && discussionElement.scrollHeight - lastCommentElem.offsetHeight;
const minOffset = discussionElement && minScrollPos && discussionElement.scrollHeight - minScrollPos;
const restoreScrollPos = React.useCallback(() => { const restoreScrollPos = React.useCallback(() => {
if (discussionElement) discussionElement.scrollTop = 0; if (discussionElement) discussionElement.scrollTop = !isMobile ? 0 : discussionElement.scrollHeight;
}, [discussionElement]); }, [discussionElement, isMobile]);
const commentsRef = React.createRef(); const commentsRef = React.createRef();
@ -79,12 +87,12 @@ export default function LivestreamChatLayout(props: Props) {
const [scrollPos, setScrollPos] = React.useState(0); const [scrollPos, setScrollPos] = React.useState(0);
const [showPinned, setShowPinned] = React.useState(true); const [showPinned, setShowPinned] = React.useState(true);
const [resolvingSuperChats, setResolvingSuperChats] = React.useState(false); const [resolvingSuperChats, setResolvingSuperChats] = React.useState(false);
const [mention, setMention] = React.useState();
const [openedPopoutWindow, setPopoutWindow] = React.useState(undefined); const [openedPopoutWindow, setPopoutWindow] = React.useState(undefined);
const [chatHidden, setChatHidden] = React.useState(false); const [chatHidden, setChatHidden] = React.useState(false);
const [didInitialScroll, setDidInitialScroll] = React.useState(false);
const [bottomScrollTop, setBottomScrollTop] = React.useState(0);
const quickMention = const recentScrollPos = isMobile ? (bottomScrollTop > 0 && minOffset ? bottomScrollTop - minOffset : 0) : 0;
mention && formatLbryUrlForWeb(mention).substring(1, formatLbryUrlForWeb(mention).indexOf(':') + 3);
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const commentsToDisplay = viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByAmount; const commentsToDisplay = viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByAmount;
const commentsLength = commentsToDisplay && commentsToDisplay.length; const commentsLength = commentsToDisplay && commentsToDisplay.length;
@ -100,6 +108,7 @@ export default function LivestreamChatLayout(props: Props) {
} }
} }
setViewMode(VIEW_MODES.SUPERCHAT); setViewMode(VIEW_MODES.SUPERCHAT);
if (setCustomViewMode) setCustomViewMode(VIEW_MODES.SUPERCHAT);
} }
React.useEffect(() => { React.useEffect(() => {
@ -115,12 +124,26 @@ export default function LivestreamChatLayout(props: Props) {
} }
}, [claimId, uri, doCommentList, doSuperChatList]); }, [claimId, uri, doCommentList, doSuperChatList]);
React.useEffect(() => {
if (
isMobile &&
discussionElement &&
viewMode === VIEW_MODES.CHAT &&
!didInitialScroll &&
discussionElement.scrollTop < discussionElement.scrollHeight
) {
discussionElement.scrollTop = discussionElement.scrollHeight;
setDidInitialScroll(true);
setBottomScrollTop(discussionElement.scrollTop);
}
}, [didInitialScroll, discussionElement, isMobile, viewMode]);
// Register scroll handler (TODO: Should throttle/debounce) // Register scroll handler (TODO: Should throttle/debounce)
React.useEffect(() => { React.useEffect(() => {
function handleScroll() { function handleScroll() {
if (discussionElement) { if (discussionElement) {
const scrollTop = discussionElement.scrollTop; const scrollTop = discussionElement.scrollTop;
if (scrollTop !== scrollPos) { if (!scrollPos || scrollTop !== scrollPos) {
setScrollPos(scrollTop); setScrollPos(scrollTop);
} }
} }
@ -136,12 +159,13 @@ export default function LivestreamChatLayout(props: Props) {
React.useEffect(() => { React.useEffect(() => {
if (discussionElement && commentsLength > 0) { if (discussionElement && commentsLength > 0) {
// Only update comment scroll if the user hasn't scrolled up to view old comments // Only update comment scroll if the user hasn't scrolled up to view old comments
if (scrollPos >= 0) { // $FlowFixMe
if (scrollPos && (!isMobile || recentScrollPos) && scrollPos >= recentScrollPos) {
// +ve scrollPos: not scrolled (Usually, there'll be a few pixels beyond 0). // +ve scrollPos: not scrolled (Usually, there'll be a few pixels beyond 0).
// -ve scrollPos: user scrolled. // -ve scrollPos: user scrolled.
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Use a timer here to ensure we reset after the new comment has been rendered. // Use a timer here to ensure we reset after the new comment has been rendered.
discussionElement.scrollTop = 0; discussionElement.scrollTop = !isMobile ? 0 : discussionElement.scrollHeight + 999;
}, COMMENT_SCROLL_TIMEOUT); }, COMMENT_SCROLL_TIMEOUT);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
@ -283,7 +307,6 @@ export default function LivestreamChatLayout(props: Props) {
comment={pinnedComment} comment={pinnedComment}
key={pinnedComment.comment_id} key={pinnedComment.comment_id}
uri={uri} uri={uri}
pushMention={setMention}
handleDismissPin={() => setShowPinned(false)} handleDismissPin={() => setShowPinned(false)}
isMobile isMobile
/> />
@ -292,12 +315,7 @@ export default function LivestreamChatLayout(props: Props) {
) : ( ) : (
showPinned && ( showPinned && (
<div className="livestream-pinned__wrapper"> <div className="livestream-pinned__wrapper">
<LivestreamComment <LivestreamComment comment={pinnedComment} key={pinnedComment.comment_id} uri={uri} />
comment={pinnedComment}
key={pinnedComment.comment_id}
uri={uri}
pushMention={setMention}
/>
<Button <Button
title={__('Dismiss pinned comment')} title={__('Dismiss pinned comment')}
@ -316,23 +334,18 @@ export default function LivestreamChatLayout(props: Props) {
<Spinner /> <Spinner />
</div> </div>
) : ( ) : (
<LivestreamComments <LivestreamComments uri={uri} commentsToDisplay={commentsToDisplay} isMobile={isMobile} />
uri={uri}
commentsToDisplay={commentsToDisplay}
pushMention={setMention}
isMobile={isMobile}
/>
)} )}
{scrollPos < 0 && ( {scrollPos && (!isMobile || recentScrollPos) && scrollPos < recentScrollPos && viewMode === VIEW_MODES.CHAT ? (
<Button <Button
button="secondary" button="secondary"
className="livestreamComments__scrollToRecent" className="livestream-comments__scroll-to-recent"
label={viewMode === VIEW_MODES.CHAT ? __('Recent Comments') : __('Recent Tips')} label={viewMode === VIEW_MODES.CHAT ? __('Recent Comments') : __('Recent Tips')}
onClick={restoreScrollPos} onClick={restoreScrollPos}
iconRight={ICONS.DOWN} iconRight={ICONS.DOWN}
/> />
)} ) : null}
<div className="livestream__comment-create"> <div className="livestream__comment-create">
<CommentCreate <CommentCreate
@ -341,8 +354,13 @@ export default function LivestreamChatLayout(props: Props) {
embed={embed} embed={embed}
uri={uri} uri={uri}
onDoneReplying={restoreScrollPos} onDoneReplying={restoreScrollPos}
pushedMention={quickMention} onSlimInputClick={
setPushedMention={setMention} scrollPos &&
recentScrollPos &&
scrollPos >= recentScrollPos &&
viewMode === VIEW_MODES.CHAT &&
restoreScrollPos
}
/> />
</div> </div>
</div> </div>

View file

@ -46,6 +46,8 @@ export default function LivestreamComment(props: Props) {
timestamp, timestamp,
} = comment; } = comment;
const commentRef = React.useRef();
const [hasUserMention, setUserMention] = React.useState(false); const [hasUserMention, setUserMention] = React.useState(false);
const isStreamer = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri; const isStreamer = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
@ -54,12 +56,24 @@ export default function LivestreamComment(props: Props) {
const isSticker = Boolean(stickerUrlFromMessage); const isSticker = Boolean(stickerUrlFromMessage);
const timePosted = timestamp * 1000; const timePosted = timestamp * 1000;
const commentIsMine = comment.channel_id && isMyComment(comment.channel_id); const commentIsMine = comment.channel_id && isMyComment(comment.channel_id);
const discussionElement = document.querySelector('.livestream__comments--mobile');
const currentComment = commentRef && commentRef.current;
const minScrollPos =
discussionElement && currentComment && discussionElement.scrollHeight - currentComment.offsetHeight;
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine // todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
function isMyComment(channelId: string) { function isMyComment(channelId: string) {
return myChannelIds ? myChannelIds.includes(channelId) : false; return myChannelIds ? myChannelIds.includes(channelId) : false;
} }
// For every new <LivestreamComment /> component that is rendered on mobile view,
// keep the scroll at the bottom (newest)
React.useEffect(() => {
if (isMobile && discussionElement && minScrollPos && discussionElement.scrollTop >= minScrollPos) {
discussionElement.scrollTop = discussionElement.scrollHeight;
}
}, [discussionElement, isMobile, minScrollPos]);
return ( return (
<li <li
className={classnames('livestream__comment', { className={classnames('livestream__comment', {
@ -68,6 +82,7 @@ export default function LivestreamComment(props: Props) {
'livestream__comment--mentioned': hasUserMention, 'livestream__comment--mentioned': hasUserMention,
'livestream__comment--mobile': isMobile, 'livestream__comment--mobile': isMobile,
})} })}
ref={commentRef}
> >
{supportAmount > 0 && ( {supportAmount > 0 && (
<div className="livestreamComment__superchatBanner"> <div className="livestreamComment__superchatBanner">

View file

@ -44,8 +44,8 @@ export default function LivestreamComments(props: Props) {
/* top to bottom comment display */ /* top to bottom comment display */
if (!fetchingComments && commentsToDisplay && commentsToDisplay.length > 0) { if (!fetchingComments && commentsToDisplay && commentsToDisplay.length > 0) {
return ( return isMobile ? (
<div className="livestream__comments"> <div className="livestream__comments--mobile">
{commentsToDisplay {commentsToDisplay
.slice(0) .slice(0)
.reverse() .reverse()
@ -55,10 +55,16 @@ export default function LivestreamComments(props: Props) {
key={comment.comment_id} key={comment.comment_id}
uri={uri} uri={uri}
forceUpdate={forceUpdate} forceUpdate={forceUpdate}
isMobile={isMobile} isMobile
/> />
))} ))}
</div> </div>
) : (
<div className="livestream__comments">
{commentsToDisplay.map((comment) => (
<LivestreamComment comment={comment} key={comment.comment_id} uri={uri} forceUpdate={forceUpdate} />
))}
</div>
); );
} }

View file

@ -130,6 +130,7 @@ export default function LivestreamLayout(props: Props) {
hideHeader hideHeader
superchatsHidden={superchatsHidden} superchatsHidden={superchatsHidden}
customViewMode={chatViewMode} customViewMode={chatViewMode}
setCustomViewMode={(mode) => setChatViewMode(mode)}
/> />
</SwipeableDrawer> </SwipeableDrawer>
@ -155,7 +156,7 @@ const ChatModeSelector = (chatSelectorProps: any) => {
<Menu> <Menu>
<MenuButton> <MenuButton>
<span className="swipeable-drawer__title-menu"> <span className="swipeable-drawer__title-menu">
{chatViewMode === VIEW_MODES.CHAT ? __('Live Chat') : __('Super Chats')} {chatViewMode === VIEW_MODES.CHAT ? __('Live Chat') : __('HyperChats')}
<Icon icon={ICONS.DOWN} /> <Icon icon={ICONS.DOWN} />
</span> </span>
</MenuButton> </MenuButton>

View file

@ -11,13 +11,27 @@ type Props = {
messageValue: string, messageValue: string,
inputDefaultProps: any, inputDefaultProps: any,
inputRef: any, inputRef: any,
handleEmojis: () => any, submitButtonRef?: any,
claimIsMine?: boolean,
toggleSelectors: () => any,
handleTip: (isLBC: boolean) => void, handleTip: (isLBC: boolean) => void,
handleSubmit: () => any, handleSubmit: () => any,
handlePreventClick?: () => void,
}; };
const TextareaSuggestionsInput = (props: Props) => { const TextareaSuggestionsInput = (props: Props) => {
const { params, messageValue, inputRef, inputDefaultProps, handleEmojis, handleTip, handleSubmit } = props; const {
params,
messageValue,
inputRef,
inputDefaultProps,
submitButtonRef,
claimIsMine,
toggleSelectors,
handleTip,
handleSubmit,
handlePreventClick,
} = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -26,15 +40,42 @@ const TextareaSuggestionsInput = (props: Props) => {
const autocompleteProps = { InputProps, disabled, fullWidth, id, inputProps }; const autocompleteProps = { InputProps, disabled, fullWidth, id, inputProps };
if (isMobile) { if (isMobile) {
InputProps.startAdornment = <Button icon={ICONS.STICKER} onClick={handleEmojis} />; InputProps.startAdornment = (
<Button
icon={ICONS.STICKER}
onClick={() => {
if (handlePreventClick) handlePreventClick();
toggleSelectors();
}}
/>
);
InputProps.endAdornment = ( InputProps.endAdornment = (
<> <>
<Button icon={ICONS.LBC} onClick={() => handleTip(true)} /> {!claimIsMine && (
<Button icon={ICONS.FINANCE} onClick={() => handleTip(false)} /> <Button
disabled={!messageValue || messageValue.length === 0}
icon={ICONS.LBC}
onClick={() => handleTip(true)}
/>
)}
<Zoom in={messageValue && messageValue.length > 0} mountOnEnter unmountOnExit> {!claimIsMine && (
<Button
disabled={!messageValue || messageValue.length === 0}
icon={ICONS.FINANCE}
onClick={() => handleTip(false)}
/>
)}
<Zoom in={messageValue ? messageValue.length > 0 : undefined} mountOnEnter unmountOnExit>
<div> <div>
<Button button="primary" icon={ICONS.SUBMIT} iconColor="red" onClick={() => handleSubmit()} /> <Button
ref={submitButtonRef}
button="primary"
icon={ICONS.SUBMIT}
iconColor="red"
onClick={() => handleSubmit()}
/>
</div> </div>
</Zoom> </Zoom>
</> </>

View file

@ -56,14 +56,18 @@ type Props = {
type?: string, type?: string,
uri?: string, uri?: string,
value: any, value: any,
autoFocus?: boolean,
submitButtonRef?: any,
claimIsMine?: boolean,
doResolveUris: (uris: Array<string>, cache: boolean) => void, doResolveUris: (uris: Array<string>, cache: boolean) => void,
doSetMentionSearchResults: (query: string, uris: Array<string>) => void, doSetMentionSearchResults: (query: string, uris: Array<string>) => void,
onBlur: (any) => any, onBlur: (any) => any,
onChange: (any) => any, onChange: (any) => any,
onFocus: (any) => any, onFocus: (any) => any,
handleEmojis: () => any, toggleSelectors: () => any,
handleTip: (isLBC: boolean) => any, handleTip: (isLBC: boolean) => any,
handleSubmit: () => any, handleSubmit: () => any,
handlePreventClick?: () => void,
}; };
export default function TextareaWithSuggestions(props: Props) { export default function TextareaWithSuggestions(props: Props) {
@ -85,14 +89,18 @@ export default function TextareaWithSuggestions(props: Props) {
searchQuery, searchQuery,
type, type,
value: messageValue, value: messageValue,
autoFocus,
submitButtonRef,
claimIsMine,
doResolveUris, doResolveUris,
doSetMentionSearchResults, doSetMentionSearchResults,
onBlur, onBlur,
onChange, onChange,
onFocus, onFocus,
handleEmojis, toggleSelectors,
handleTip, handleTip,
handleSubmit, handleSubmit,
handlePreventClick,
} = props; } = props;
const inputDefaultProps = { className, placeholder, maxLength, type, disabled }; const inputDefaultProps = { className, placeholder, maxLength, type, disabled };
@ -290,6 +298,13 @@ export default function TextareaWithSuggestions(props: Props) {
/** Effects **/ /** Effects **/
/** ------- **/ /** ------- **/
React.useEffect(() => {
if (!autoFocus) return;
const inputElement = inputRef && inputRef.current;
if (inputElement) inputElement.focus();
}, [autoFocus, inputRef]);
React.useEffect(() => { React.useEffect(() => {
if (!isMention) return; if (!isMention) return;
@ -400,9 +415,12 @@ export default function TextareaWithSuggestions(props: Props) {
messageValue={messageValue} messageValue={messageValue}
inputRef={inputRef} inputRef={inputRef}
inputDefaultProps={inputDefaultProps} inputDefaultProps={inputDefaultProps}
handleEmojis={handleEmojis} toggleSelectors={toggleSelectors}
handleTip={handleTip} handleTip={handleTip}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
handlePreventClick={handlePreventClick}
submitButtonRef={submitButtonRef}
claimIsMine={claimIsMine}
/> />
)} )}
renderOption={(optionProps, option) => ( renderOption={(optionProps, option) => (

View file

@ -44,6 +44,7 @@ type Props = {
uri: string, uri: string,
isTipOnly?: boolean, isTipOnly?: boolean,
hasSelectedTab?: string, hasSelectedTab?: string,
customText?: string,
doHideModal: () => void, doHideModal: () => void,
doSendCashTip: (TipParams, boolean, UserParams, string, ?string) => string, doSendCashTip: (TipParams, boolean, UserParams, string, ?string) => string,
doSendTip: (SupportParams, boolean) => void, // function that comes from lbry-redux doSendTip: (SupportParams, boolean) => void, // function that comes from lbry-redux
@ -69,6 +70,7 @@ export default function WalletSendTip(props: Props) {
uri, uri,
isTipOnly, isTipOnly,
hasSelectedTab, hasSelectedTab,
customText,
doHideModal, doHideModal,
doSendCashTip, doSendCashTip,
doSendTip, doSendTip,
@ -328,7 +330,7 @@ export default function WalletSendTip(props: Props) {
button="primary" button="primary"
type="submit" type="submit"
disabled={fetchingChannels || isPending || tipError || !tipAmount || disableSubmitButton} disabled={fetchingChannels || isPending || tipError || !tipAmount || disableSubmitButton}
label={buildButtonText()} label={customText || buildButtonText()}
/> />
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>} {fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</div> </div>

View file

@ -71,7 +71,6 @@ function WalletTipAmountSelector(props: Props) {
// if it's fiat but there's no card saved OR the creator can't receive fiat tips // if it's fiat but there's no card saved OR the creator can't receive fiat tips
const shouldDisableFiatSelectors = activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip); const shouldDisableFiatSelectors = activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip);
if (setDisableSubmitButton) setDisableSubmitButton(shouldDisableFiatSelectors);
// setup variables for tip API // setup variables for tip API
const channelClaimId = claim ? (claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id) : undefined; const channelClaimId = claim ? (claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id) : undefined;
@ -103,6 +102,10 @@ function WalletTipAmountSelector(props: Props) {
} }
} }
React.useEffect(() => {
if (setDisableSubmitButton) setDisableSubmitButton(shouldDisableFiatSelectors);
}, [setDisableSubmitButton, shouldDisableFiatSelectors]);
React.useEffect(() => { React.useEffect(() => {
if (setConvertedAmount && exchangeRate && (!convertedAmount || convertedAmount !== amount * exchangeRate)) { if (setConvertedAmount && exchangeRate && (!convertedAmount || convertedAmount !== amount * exchangeRate)) {
setConvertedAmount(amount * exchangeRate); setConvertedAmount(amount * exchangeRate);

View file

@ -185,7 +185,7 @@ export const TECHNOLOGY = 'Technology';
export const EMOJI = 'Emoji'; export const EMOJI = 'Emoji';
export const STICKER = 'Sticker'; export const STICKER = 'Sticker';
export const EDUCATION = 'Education'; export const EDUCATION = 'Education';
export const POP_CULTURE = 'PopCulture'; export const POP_CULTURE = 'Pop Culture';
export const ODYSEE_LOGO = 'OdyseeLogo'; export const ODYSEE_LOGO = 'OdyseeLogo';
export const ODYSEE_WHITE_TEXT = 'OdyseeLogoWhiteText'; export const ODYSEE_WHITE_TEXT = 'OdyseeLogoWhiteText';
export const ODYSEE_DARK_TEXT = 'OdyseeLogoDarkText'; export const ODYSEE_DARK_TEXT = 'OdyseeLogoDarkText';

View file

@ -9,13 +9,14 @@ type Props = {
isSupport: boolean, isSupport: boolean,
isTipOnly?: boolean, isTipOnly?: boolean,
hasSelectedTab?: string, hasSelectedTab?: string,
customText?: string,
doHideModal: () => void, doHideModal: () => void,
setAmount?: (number) => void, setAmount?: (number) => void,
}; };
class ModalSendTip extends React.PureComponent<Props> { class ModalSendTip extends React.PureComponent<Props> {
render() { render() {
const { uri, claimIsMine, isTipOnly, hasSelectedTab, doHideModal, setAmount } = this.props; const { uri, claimIsMine, isTipOnly, hasSelectedTab, customText, doHideModal, setAmount } = this.props;
return ( return (
<Modal onAborted={doHideModal} isOpen type="card"> <Modal onAborted={doHideModal} isOpen type="card">
@ -25,6 +26,7 @@ class ModalSendTip extends React.PureComponent<Props> {
onCancel={doHideModal} onCancel={doHideModal}
isTipOnly={isTipOnly} isTipOnly={isTipOnly}
hasSelectedTab={hasSelectedTab} hasSelectedTab={hasSelectedTab}
customText={customText}
setAmount={setAmount} setAmount={setAmount}
/> />
</Modal> </Modal>

View file

@ -72,7 +72,8 @@ export default function FilePage(props: Props) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [showComments, setShowComments] = React.useState(undefined); // Auto-open the drawer on Mobile view if there is a linked comment
const [showComments, setShowComments] = React.useState(linkedCommentId);
const cost = costInfo ? costInfo.cost : null; const cost = costInfo ? costInfo.cost : null;
const hasFileInfo = fileInfo !== undefined; const hasFileInfo = fileInfo !== undefined;
@ -186,11 +187,7 @@ export default function FilePage(props: Props) {
); );
} }
const commentsListElement = commentsDisabled ? ( const commentsListProps = { uri, linkedCommentId };
<Empty text={__('The creator of this content has disabled comments.')} />
) : (
<CommentsList uri={uri} linkedCommentId={linkedCommentId} />
);
return ( return (
<Page className="file-page" filePage isMarkdown={isMarkdown}> <Page className="file-page" filePage isMarkdown={isMarkdown}>
@ -216,20 +213,22 @@ export default function FilePage(props: Props) {
{RENDER_MODES.FLOATING_MODES.includes(renderMode) && <FileTitleSection uri={uri} />} {RENDER_MODES.FLOATING_MODES.includes(renderMode) && <FileTitleSection uri={uri} />}
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
{isMobile ? ( {commentsDisabled ? (
<Empty text={__('The creator of this content has disabled comments.')} />
) : isMobile ? (
<> <>
<SwipeableDrawer <SwipeableDrawer
open={Boolean(showComments)} open={Boolean(showComments)}
toggleDrawer={() => setShowComments(!showComments)} toggleDrawer={() => setShowComments(!showComments)}
title={commentsListTitle} title={commentsListTitle}
> >
{commentsListElement} <CommentsList {...commentsListProps} />
</SwipeableDrawer> </SwipeableDrawer>
<DrawerExpandButton label={commentsListTitle} toggleDrawer={() => setShowComments(!showComments)} /> <DrawerExpandButton label={commentsListTitle} toggleDrawer={() => setShowComments(!showComments)} />
</> </>
) : ( ) : (
commentsListElement <CommentsList {...commentsListProps} />
)} )}
</React.Suspense> </React.Suspense>
</section> </section>

View file

@ -14,6 +14,44 @@
overflow: visible; overflow: visible;
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);
@media (max-width: $breakpoint-small) {
overflow-y: scroll;
height: 100%;
.card__main-actions {
margin-top: 0px !important;
.comment__sort {
margin: 0px !important;
display: inline;
button {
padding: var(--spacing-xxs);
span {
font-size: var(--font-xxsmall);
}
}
}
> .button--alt {
padding: var(--spacing-xxs) var(--spacing-s);
}
.comment__sort + button {
margin: 0px var(--spacing-xxs);
}
.button + .commentCreate {
margin-top: var(--spacing-xxs);
}
}
}
}
.card--comments-list {
@extend .card--enable-overflow;
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
overflow-y: scroll; overflow-y: scroll;
height: 100%; height: 100%;
@ -362,7 +400,7 @@
margin: 0; margin: 0;
} }
.media__subtitle--centered::before { span + .media__subtitle--centered::before {
content: ''; content: '';
margin: 0 5px; margin: 0 5px;
} }
@ -491,3 +529,10 @@
} }
} }
} }
.ReactModalPortal {
.button--close {
top: 0;
right: 0;
}
}

View file

@ -21,10 +21,16 @@ $thumbnailWidthSmall: 1rem;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; align-items: flex-end;
} }
@media (min-width: $breakpoint-small) {
fieldset-section + .section {
margin-top: var(--spacing-m);
}
}
} }
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
.commentCreate + .empty__wrap { .empty__wrap {
p { p {
font-size: var(--font-small); font-size: var(--font-small);
text-align: center; text-align: center;
@ -32,6 +38,16 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.comment-create--drawer {
.MuiPaper-root {
background-color: var(--color-background) !important;
span {
color: var(--color-text);
}
}
}
.commentCreate--reply { .commentCreate--reply {
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
position: relative; position: relative;
@ -71,6 +87,15 @@ $thumbnailWidthSmall: 1rem;
fieldset-section { fieldset-section {
font-size: var(--font-xxsmall); font-size: var(--font-xxsmall);
} }
span {
font-size: var(--font-xxsmall);
}
select {
height: 1rem;
margin: var(--spacing-xxs) 0px;
}
} }
} }
@ -86,12 +111,25 @@ $thumbnailWidthSmall: 1rem;
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
font-size: var(--font-large); font-size: var(--font-large);
} }
@media (max-width: $breakpoint-small) {
padding: var(--spacing-xs);
span {
font-size: var(--font-xsmall);
}
}
} }
.commentCreate__minAmountNotice { .comment-create__min-amount-notice {
.icon { .icon {
margin-bottom: -3px; // TODO fix few instances of these (find "-2px") margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
} }
@media (max-width: $breakpoint-small) {
margin: 0px;
font-size: var(--font-xsmall);
}
} }
.commentCreate__stickerPreview { .commentCreate__stickerPreview {
@ -131,4 +169,13 @@ $thumbnailWidthSmall: 1rem;
margin-left: var(--spacing-xxs); margin-left: var(--spacing-xxs);
} }
} }
@media (max-width: $breakpoint-small) {
padding: var(--spacing-xs);
height: 7rem;
span {
font-size: var(--font-xsmall);
}
}
} }

View file

@ -0,0 +1,119 @@
@import '../init/breakpoints';
$emote-item-size--small: 2.5rem;
$emote-item-size--big: 3rem;
$sticker-item-size: 5rem;
// -- EMOJIS --
.selector-menu {
overflow-y: scroll;
overflow-x: hidden;
@media (min-width: $breakpoint-small) {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
max-height: 25vh;
padding: var(--spacing-xs);
}
@media (max-width: $breakpoint-small) {
max-height: 30vh;
padding-top: var(--spacing-s);
&::-webkit-scrollbar {
width: 0 !important;
}
}
}
.emote-selector__items {
display: grid;
grid-template-columns: repeat(auto-fit, $emote-item-size--small);
justify-items: center;
justify-content: space-evenly;
button {
margin: 0px !important;
padding: var(--spacing-xs);
height: unset;
&:first-child {
margin-right: 0px;
}
@media (max-width: $breakpoint-small) {
&:focus,
&:hover {
background-color: transparent !important;
}
}
@media (min-width: $breakpoint-small) {
padding: var(--spacing-s);
}
}
@media (min-width: $breakpoint-small) {
grid-template-columns: repeat(auto-fit, $emote-item-size--big);
}
}
// -- STICKERS --
.selector-menu--stickers {
@extend .selector-menu;
padding-top: 0px;
@media (min-width: $breakpoint-small) {
border: 0;
}
}
.sticker-selector__items {
@extend .emote-selector__items;
grid-template-columns: repeat(auto-fit, $sticker-item-size);
.button--file-action {
overflow: hidden;
margin: unset;
padding: var(--spacing-xs);
height: unset;
.sticker-item--priced {
display: flex;
flex-direction: column;
align-items: center;
img {
margin-bottom: var(--spacing-s);
}
.superChat--light {
position: absolute;
display: inline;
bottom: 0;
}
}
img {
margin: 0px;
}
}
}
.sticker-selector__row-title {
font-size: var(--font-small);
padding-left: var(--spacing-xxs);
width: 100%;
position: sticky;
top: 0px;
background-color: var(--color-tabs-background);
z-index: 1;
@media (min-width: $breakpoint-small) {
padding-top: var(--spacing-xs);
}
@media (max-width: $breakpoint-small) {
font-size: var(--font-xsmall);
}
}

View file

@ -499,6 +499,10 @@ $thumbnailWidthSmall: 1.8rem;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
@media (max-width: $breakpoint-small) {
height: 5rem;
}
} }
.emote { .emote {

View file

@ -1,40 +0,0 @@
@import '../init/breakpoints';
.emoteSelector {
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);
}
.emoteSelector__list {
display: flex;
flex-wrap: wrap;
overflow-y: scroll;
overflow-x: hidden;
max-height: 25vh;
padding: var(--spacing-s);
.emoteSelector__listRowItems {
display: flex;
flex-wrap: wrap;
.button--file-action {
margin: var(--spacing-xxs);
padding: var(--spacing-xs);
.button__content {
justify-content: center;
align-items: center;
align-content: center;
width: 1.5rem;
height: 1.5rem;
span {
margin: auto;
font-size: var(--font-large);
}
}
}
}
}

View file

@ -122,6 +122,16 @@ $recent-msg-button__height: 2rem;
} }
.livestream__comments { .livestream__comments {
display: flex;
flex-direction: column-reverse;
font-size: var(--font-small);
overflow-y: scroll;
overflow-x: visible;
padding-top: var(--spacing-s);
width: 100%;
}
.livestream__comments--mobile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: var(--font-small); font-size: var(--font-small);
@ -131,10 +141,9 @@ $recent-msg-button__height: 2rem;
width: 100%; width: 100%;
} }
.livestreamComments__scrollToRecent { .livestream-comments__scroll-to-recent {
margin-top: -$recent-msg-button__height; margin-top: -$recent-msg-button__height;
align-self: center; align-self: center;
margin-bottom: var(--spacing-xs);
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
padding: var(--spacing-xxs) var(--spacing-s); padding: var(--spacing-xxs) var(--spacing-s);
opacity: 0.9; opacity: 0.9;
@ -142,6 +151,14 @@ $recent-msg-button__height: 2rem;
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
@media (min-width: $breakpoint-small) {
margin-bottom: var(--spacing-xs);
}
@media (max-width: $breakpoint-small) {
bottom: var(--spacing-xxs);
}
} }
.livestream__comment-create { .livestream__comment-create {
@ -150,7 +167,7 @@ $recent-msg-button__height: 2rem;
margin-top: auto; margin-top: auto;
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
padding: var(--spacing-xxs); padding: 0px;
span, span,
select, select,
@ -186,7 +203,7 @@ $recent-msg-button__height: 2rem;
.livestream-superchats__wrapper--mobile { .livestream-superchats__wrapper--mobile {
@extend .livestream-superchats__wrapper; @extend .livestream-superchats__wrapper;
z-index: 1300; z-index: 9999999;
width: 100%; width: 100%;
background-color: transparent; background-color: transparent;
padding: 0px; padding: 0px;
@ -224,6 +241,7 @@ $recent-msg-button__height: 2rem;
left: 0; left: 0;
right: 0; right: 0;
width: 100%; width: 100%;
z-index: 1;
} }
} }
} }
@ -268,24 +286,15 @@ $recent-msg-button__height: 2rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
.livestream__comment { .livestream__comment {
overflow: unset; overflow: hidden;
.livestreamComment__body { .livestreamComment__body {
margin: 0px; margin: 0px;
width: 100%; width: 100%;
overflow: scroll;
.markdown-preview { &::-webkit-scrollbar {
p, width: 0 !important;
.button__label {
white-space: nowrap !important;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
a {
pointer-events: none;
}
} }
} }
} }
@ -378,6 +387,7 @@ $recent-msg-button__height: 2rem;
span { span {
font-size: var(--font-xxsmall); font-size: var(--font-xxsmall);
color: var(--color-text-subtitle);
} }
} }

View file

@ -127,6 +127,12 @@
flex: 1; flex: 1;
max-width: 100%; max-width: 100%;
} }
@media (max-width: $breakpoint-medium) {
section + .empty__wrap {
margin: var(--spacing-m);
}
}
} }
@keyframes fadeIn { @keyframes fadeIn {

View file

@ -1,95 +0,0 @@
@import '../init/breakpoints';
.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 {
overflow-y: scroll;
overflow-x: hidden;
max-height: 25vh;
padding: var(--spacing-s);
.stickerSelector__listBody-rowItems {
display: flex;
flex-wrap: wrap;
overflow-y: scroll;
overflow-x: hidden;
.button--file-action {
width: 5.5rem;
height: 6rem;
overflow: hidden;
margin: unset;
padding: var(--spacing-xxs);
.stickerItem--paid {
display: flex;
flex-direction: column;
align-items: center;
img {
margin-bottom: var(--spacing-s);
}
.superChat--light {
position: absolute;
display: inline;
bottom: 0;
}
}
img {
margin: auto;
}
@media (max-width: $breakpoint-xsmall) {
width: 3.5rem;
height: 4rem;
}
}
}
}
}

View file

@ -6,16 +6,47 @@
font-size: inherit !important; font-size: inherit !important;
color: var(--color-text) !important; color: var(--color-text) !important;
.MuiOutlinedInput-notchedOutline {
visibility: hidden;
}
.create__comment { .create__comment {
min-height: calc(var(--height-input) * 1.5) !important; min-height: calc(var(--height-input) * 1.5) !important;
} }
} }
} }
.comment-create__auth {
.MuiAutocomplete-root {
margin-bottom: var(--spacing-m);
}
}
.comment-create--drawer {
.MuiPaper-root {
.form-field__two-column,
.MuiOutlinedInput-root {
padding: 0px var(--spacing-xxs) !important;
}
}
}
.livestream__comment-create {
.MuiOutlinedInput-root {
padding: 0px var(--spacing-xxs) !important;
}
}
.MuiOutlinedInput-notchedOutline {
visibility: none;
}
.comment-create--drawer .MuiOutlinedInput-notchedOutline {
border: 1px solid var(--color-border) !important;
border-radius: 0 !important;
}
.card__main-actions .commentCreate .MuiOutlinedInput-notchedOutline {
border: 1px solid var(--color-border) !important;
border-radius: var(--border-radius) !important;
}
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
.MuiOutlinedInput-input { .MuiOutlinedInput-input {
padding: 0px var(--spacing-xxs); padding: 0px var(--spacing-xxs);
@ -25,19 +56,15 @@
font-size: var(--font-xsmall) !important; font-size: var(--font-xsmall) !important;
flex-wrap: nowrap !important; flex-wrap: nowrap !important;
color: var(--color-text) !important; color: var(--color-text) !important;
padding: 0px 9px !important; padding: 0px !important;
textarea { textarea {
border: none; border: none;
margin: 9px 0px; margin: 9px 0px;
} }
button:not(:first-of-type):not(:last-of-type) { button {
margin: 0px var(--spacing-xxs); padding: var(--spacing-xxs);
}
button + div {
margin-left: var(--spacing-xxs);
} }
.button--primary { .button--primary {
@ -130,3 +157,11 @@
.MuiAutocomplete-loading { .MuiAutocomplete-loading {
color: var(--color-text) !important; color: var(--color-text) !important;
} }
.MuiPaper-root {
.form-field__two-column {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
}

View file

@ -167,6 +167,23 @@
padding: var(--spacing-s) var(--spacing-m); padding: var(--spacing-s) var(--spacing-m);
height: var(--button-height); height: var(--button-height);
} }
@media (max-width: $breakpoint-small) {
margin-top: var(--spacing-xxs);
button {
height: 2rem;
padding: 0px var(--spacing-s);
.button__content {
height: unset;
span {
font-size: var(--font-xxsmall);
}
}
}
}
} }
.section__actions--centered { .section__actions--centered {

View file

@ -14,6 +14,45 @@
} }
} }
.commentCreate {
.tabs {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-m);
.selector-menu {
border: 0 !important;
button {
margin: var(--spacing-xxs);
}
}
}
}
.tabs__list--comment-selector {
@extend .tabs__list--channel-page;
padding-left: var(--spacing-m) !important;
border: 0 !important;
border-bottom: 1px solid var(--color-border) !important;
border-radius: 0 !important;
margin-bottom: 0px !important;
+ .tab__divider {
display: none;
}
@media (max-width: $breakpoint-small) {
position: sticky;
padding: 0px;
height: 3rem !important;
button {
font-size: var(--font-xxsmall);
}
}
}
.tabs__list--channel-page { .tabs__list--channel-page {
padding-left: calc(var(--channel-thumbnail-width) + var(--spacing-xl)); padding-left: calc(var(--channel-thumbnail-width) + var(--spacing-xl));
padding-right: var(--spacing-m); padding-right: var(--spacing-m);