From 0b41fc041a406afa363d38a3d5888070a8bddfed Mon Sep 17 00:00:00 2001 From: jessopb <36554050+jessopb@users.noreply.github.com> Date: Mon, 24 Jan 2022 11:07:09 -0500 Subject: [PATCH] Bringing in emotes, stickers, and refactors from ody (#7435) * [New Feature] Comment Emotes (#125) * Refactor form-field * Create new Emote Menu * Add Emotes * Add Emote Selector and Emote Comment creation ability * Fix and Split CSS * [New Feature] Stickers (#131) * Refactor filePrice * Refactor Wallet Tip Components * Add backend sticker support for comments * Add stickers * Refactor commentCreate * Add Sticker Selector and sticker comment creation * Add stickers display to comments and hyperchats * Fix wrong checks for total Super Chats * Stickers/emojis fall out / improvements (#220) * Fix error logs * Improve LBC sticker flow/clarity * Show inline error if custom sticker amount below min * Sort emojis alphabetically * Improve loading of Images * Improve quality and display of emojis and fix CSS * Display both USD and LBC prices * Default to LBC tip if creator can't receive USD * Don't clear text-field after sticker is sent * Refactor notification component * Handle notifications * Don't show profile pic on sticker livestream comments * Change Sticker icon * Fix wording and number rounding * Fix blurring emojis * Disable non functional emote buttons * new Stickers! (#248) * Add new stickers (#347) * Fix cancel sending sticker (#447) * Refactor scrollbar CSS for portal components outside of main Refactor channelMention suggestions into new textareaSuggestions component Install @mui/material packages Move channel mentioning to use @mui/Autocomplete combobox without search functionality Add support for suggesting Emotes while typing ':' Improve label to display matching term Add back and improved support for searching while mentioning Add support for suggesting emojis Fix non concatenated strings Add key to groups and options Fix dispatch props Fix Popper positioning to be consistent Fix and Improve searching Add back support for Winning Uri Filter default emojis with the same name as emotes Remove unused topSuggestion component Fix text color on darkmode Fix livestream updating state from both websocket and reducer and causing double of the same comments to appear Fix blur and focus commentCreate events Fix no name after @ error * desktop tweaks Co-authored-by: saltrafael <76502841+saltrafael@users.noreply.github.com> Co-authored-by: Thomas Zarebczan Co-authored-by: Rafael --- flow-typed/search.js | 3 + package.json | 3 + static/app-strings.json | 12 +- ui/component/app/view.jsx | 19 +- .../channelMentionSuggestion/index.js | 10 - .../channelMentionSuggestion/view.jsx | 32 - .../channelMentionSuggestions/index.js | 37 - .../channelMentionSuggestions/view.jsx | 277 ------- .../channelMentionTopSuggestion/index.js | 16 - .../channelMentionTopSuggestion/view.jsx | 49 -- ui/component/comment/view.jsx | 7 + ui/component/commentCreate/emote-selector.jsx | 66 ++ ui/component/commentCreate/index.js | 16 +- .../commentCreate/sticker-selector.jsx | 94 +++ ui/component/commentCreate/view.jsx | 718 ++++++++++-------- ui/component/common/credit-amount.jsx | 110 +-- .../common/form-components/form-field.jsx | 481 ++++++------ ui/component/common/icon-custom.jsx | 18 + ui/component/common/markdown-preview.jsx | 69 +- ui/component/filePrice/view.jsx | 71 +- ui/component/notification/index.js | 10 +- ui/component/notification/view.jsx | 156 ++-- ui/component/optimizedImage/view.jsx | 9 +- ui/component/recommendedContent/index.js | 4 +- ui/component/textareaSuggestionsItem/index.js | 9 + ui/component/textareaSuggestionsItem/view.jsx | 46 ++ ui/component/textareaWithSuggestions/index.js | 46 ++ ui/component/textareaWithSuggestions/view.jsx | 417 ++++++++++ ui/component/viewers/videoViewer/index.js | 4 +- ui/component/walletSendTip/index.js | 34 +- ui/component/walletSendTip/view.jsx | 469 ++++-------- .../walletSpendableBalanceHelp/index.js | 4 +- .../walletSpendableBalanceHelp/view.jsx | 28 +- ui/component/walletTipAmountSelector/index.js | 8 +- ui/component/walletTipAmountSelector/view.jsx | 462 +++++------ ui/constants/action_types.js | 1 + ui/constants/emotes.js | 96 +++ ui/constants/icons.js | 2 + ui/constants/stickers.js | 139 ++++ ui/effects/use-throttle.js | 8 +- ui/page/discover/view.jsx | 1 - ui/redux/actions/comments.js | 19 +- ui/redux/actions/search.js | 7 + ui/redux/actions/wallet.js | 46 +- ui/redux/reducers/search.js | 8 + ui/redux/selectors/comments.js | 162 +++- ui/redux/selectors/search.js | 191 +++-- ui/redux/selectors/subscriptions.js | 5 + ui/scss/all.scss | 2 +- ui/scss/component/_button.scss | 6 - ui/scss/component/_channel-mention.scss | 125 --- ui/scss/component/_comment-create.scss | 121 +++ ui/scss/component/_comments.scss | 90 +-- ui/scss/component/_emote-selector.scss | 40 + ui/scss/component/_file-price.scss | 144 ++++ ui/scss/component/_form-field.scss | 8 - ui/scss/component/_main.scss | 1 + ui/scss/component/_markdown-preview.scss | 2 +- ui/scss/component/_notification.scss | 23 +- ui/scss/component/_purchase.scss | 149 ---- ui/scss/component/_sticker-selector.scss | 82 ++ ui/scss/component/_textarea-suggestions.scss | 92 +++ ui/scss/component/_wallet-tip-selector.scss | 3 + ui/scss/init/_gui.scss | 21 + ui/util/comments.js | 22 +- ui/util/remark-emote.js | 125 +++ ui/util/remark-lbry.js | 2 +- .../themes/odysee/component/_form-field.scss | 711 +++++++++++++++++ yarn.lock | 336 +++++++- 69 files changed, 4280 insertions(+), 2324 deletions(-) delete mode 100644 ui/component/channelMentionSuggestion/index.js delete mode 100644 ui/component/channelMentionSuggestion/view.jsx delete mode 100644 ui/component/channelMentionSuggestions/index.js delete mode 100644 ui/component/channelMentionSuggestions/view.jsx delete mode 100644 ui/component/channelMentionTopSuggestion/index.js delete mode 100644 ui/component/channelMentionTopSuggestion/view.jsx create mode 100644 ui/component/commentCreate/emote-selector.jsx create mode 100644 ui/component/commentCreate/sticker-selector.jsx create mode 100644 ui/component/textareaSuggestionsItem/index.js create mode 100644 ui/component/textareaSuggestionsItem/view.jsx create mode 100644 ui/component/textareaWithSuggestions/index.js create mode 100644 ui/component/textareaWithSuggestions/view.jsx create mode 100644 ui/constants/emotes.js create mode 100644 ui/constants/stickers.js delete mode 100644 ui/scss/component/_channel-mention.scss create mode 100644 ui/scss/component/_comment-create.scss create mode 100644 ui/scss/component/_emote-selector.scss create mode 100644 ui/scss/component/_file-price.scss create mode 100644 ui/scss/component/_sticker-selector.scss create mode 100644 ui/scss/component/_textarea-suggestions.scss create mode 100644 ui/scss/component/_wallet-tip-selector.scss create mode 100644 ui/util/remark-emote.js create mode 100644 web/scss/themes/odysee/component/_form-field.scss diff --git a/flow-typed/search.js b/flow-typed/search.js index b795f9c12..26f4cbba9 100644 --- a/flow-typed/search.js +++ b/flow-typed/search.js @@ -29,8 +29,10 @@ declare type SearchOptions = { declare type SearchState = { options: SearchOptions, resultsByQuery: {}, + results: Array, hasReachedMaxResultsLength: {}, searching: boolean, + mentionQuery: string, }; declare type SearchSuccess = { @@ -41,6 +43,7 @@ declare type SearchSuccess = { size: number, uris: Array, recsys: string, + query: string, }, }; diff --git a/package.json b/package.json index 49e2f572b..9a83474d8 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "postinstall:warning": "echo '\n\nWARNING\n\nNot all node modules were installed because NODE_ENV is set to \"production\".\nThis should only be set after installing dependencies with \"yarn\". The app will not work.\n\n'" }, "dependencies": { + "@emotion/react": "^11.6.0", + "@emotion/styled": "^11.6.0", + "@mui/material": "^5.2.1", "@electron/remote": "^2.0.1", "@ungap/from-entries": "^0.2.1", "auto-launch": "^5.0.5", diff --git a/static/app-strings.json b/static/app-strings.json index 99b65f1ec..6e93c4912 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -2235,9 +2235,17 @@ "Network Data Hosting allows the p2p network to store blobs unrelated to your browsing.": "Network Data Hosting allows the p2p network to store blobs unrelated to your browsing.", "Content: Limit (GB)": "Content: Limit (GB)", "Network: Allow (GB)": "Network: Allow (GB)", - "Failed to view lbry://@Destiny#6/destiny-crashes-conservative-panel-w#a, please try again. If this problem persists, visit https://lbry.com/faq/support for support.": "Failed to view lbry://@Destiny#6/destiny-crashes-conservative-panel-w#a, please try again. If this problem persists, visit https://lbry.com/faq/support for support.", "A channel is required to repost on LBRY": "A channel is required to repost on LBRY", - "Failed to view lbry://@gatogalactico#9/gato-galactico-e-as-estrelas-ninja-dos#1, please try again. If this problem persists, visit https://lbry.com/faq/support for support.": "Failed to view lbry://@gatogalactico#9/gato-galactico-e-as-estrelas-ninja-dos#1, please try again. If this problem persists, visit https://lbry.com/faq/support for support.", "Admin": "Admin", + "Stickers": "Stickers", + "Different Sticker": "Different Sticker", + "LBC": "LBC", + "Add a Card": "Add a Card", + " To Tip Creators": " To Tip Creators", + "Nothing found": "Nothing found", + "From Comments": "From Comments", + "This support is priced in $USD.": "This support is priced in $USD.", + "The current exchange rate for the submitted LBC amount is ~ $%exchange_amount%.": "The current exchange rate for the submitted LBC amount is ~ $%exchange_amount%.", + "Amount of $%input_amount% LBC in USB is lower than price of $%price_amount%": "Amount of $%input_amount% LBC in USB is lower than price of $%price_amount%", "--end--": "--end--" } diff --git a/ui/component/app/view.jsx b/ui/component/app/view.jsx index aeb10d572..e442a7fcd 100644 --- a/ui/component/app/view.jsx +++ b/ui/component/app/view.jsx @@ -68,8 +68,8 @@ type Props = { syncLoop: (?boolean) => void, currentModal: any, syncFatalError: boolean, - activeChannelClaim: ?ChannelClaim, - myChannelUrls: ?Array, + activeChannelId: ?string, + myChannelClaimIds: ?Array, subscriptions: Array, setActiveChannelIfNotSet: () => void, setIncognito: (boolean) => void, @@ -103,8 +103,8 @@ function App(props: Props) { syncLoop, currentModal, syncFatalError, - myChannelUrls, - activeChannelClaim, + myChannelClaimIds, + activeChannelId, setActiveChannelIfNotSet, setIncognito, fetchModBlockedList, @@ -125,6 +125,7 @@ function App(props: Props) { const { pathname, search } = props.location; const [upgradeNagClosed, setUpgradeNagClosed] = useState(false); const [resolvedSubscriptions, setResolvedSubscriptions] = useState(false); + // const [retryingSync, setRetryingSync] = useState(false); const [sidebarOpen] = usePersistedState('sidebar', true); const showUpgradeButton = (autoUpdateDownloaded || (process.platform === 'linux' && isUpgradeAvailable)) && !upgradeNagClosed; @@ -135,10 +136,10 @@ function App(props: Props) { const sanitizedReferrerParam = rawReferrerParam && rawReferrerParam.replace(':', '#'); const userId = user && user.id; const useCustomScrollbar = !IS_MAC; - const hasMyChannels = myChannelUrls && myChannelUrls.length > 0; - const hasNoChannels = myChannelUrls && myChannelUrls.length === 0; + const hasMyChannels = myChannelClaimIds && myChannelClaimIds.length > 0; + const hasNoChannels = myChannelClaimIds && myChannelClaimIds.length === 0; const shouldMigrateLanguage = LANGUAGE_MIGRATIONS[language]; - const hasActiveChannelClaim = activeChannelClaim !== undefined; + const hasActiveChannelClaim = activeChannelId !== undefined; const isPersonalized = hasVerifiedEmail; const renderFiledrop = isAuthenticated; @@ -152,7 +153,7 @@ function App(props: Props) { if (!uploadCount) return; const handleBeforeUnload = (event) => { event.preventDefault(); - event.returnValue = 'magic'; // without setting this to something it doesn't work + event.returnValue = __('There are pending uploads.'); // without setting this to something it doesn't work }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); @@ -272,7 +273,6 @@ function App(props: Props) { } }, [previousRewardApproved, isRewardApproved]); - // @if TARGET='app' useEffect(() => { if (updatePreferences && getWalletSyncPref && readyForPrefs) { getWalletSyncPref() @@ -282,7 +282,6 @@ function App(props: Props) { }); } }, [updatePreferences, getWalletSyncPref, setReadyForSync, readyForPrefs, hasVerifiedEmail]); - // @endif // ready for sync syncs, however after signin when hasVerifiedEmail, that syncs too. useEffect(() => { diff --git a/ui/component/channelMentionSuggestion/index.js b/ui/component/channelMentionSuggestion/index.js deleted file mode 100644 index 787502cc3..000000000 --- a/ui/component/channelMentionSuggestion/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import { connect } from 'react-redux'; -import { selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims'; -import ChannelMentionSuggestion from './view'; - -const select = (state, props) => ({ - claim: selectClaimForUri(state, props.uri), - isResolvingUri: selectIsUriResolving(state, props.uri), -}); - -export default connect(select)(ChannelMentionSuggestion); diff --git a/ui/component/channelMentionSuggestion/view.jsx b/ui/component/channelMentionSuggestion/view.jsx deleted file mode 100644 index 9d7a2afdc..000000000 --- a/ui/component/channelMentionSuggestion/view.jsx +++ /dev/null @@ -1,32 +0,0 @@ -// @flow -import { ComboboxOption } from '@reach/combobox'; -import ChannelThumbnail from 'component/channelThumbnail'; -import React from 'react'; - -type Props = { - claim: ?Claim, - uri?: string, - isResolvingUri: boolean, -}; - -export default function ChannelMentionSuggestion(props: Props) { - const { claim, uri, isResolvingUri } = props; - - return !claim ? null : ( - - {isResolvingUri ? ( -
-
-
- ) : ( -
- - -
{(claim.value && claim.value.title) || claim.name}
-
{claim.name}
-
-
- )} - - ); -} diff --git a/ui/component/channelMentionSuggestions/index.js b/ui/component/channelMentionSuggestions/index.js deleted file mode 100644 index c1f635011..000000000 --- a/ui/component/channelMentionSuggestions/index.js +++ /dev/null @@ -1,37 +0,0 @@ -import { connect } from 'react-redux'; -import { selectShowMatureContent } from 'redux/selectors/settings'; -import { selectSubscriptions } from 'redux/selectors/subscriptions'; -import { withRouter } from 'react-router'; -import { makeSelectClaimForUri } from 'redux/selectors/claims'; -import { doResolveUris } from 'redux/actions/claims'; -import { selectTopLevelCommentsForUri } from 'redux/selectors/comments'; -import ChannelMentionSuggestions from './view'; - -const select = (state, props) => { - const subscriptionUris = selectSubscriptions(state).map(({ uri }) => uri); - const topLevelComments = selectTopLevelCommentsForUri(state, props.uri); - - const commentorUris = []; - // Avoid repeated commentors - topLevelComments.map(({ channel_url }) => !commentorUris.includes(channel_url) && commentorUris.push(channel_url)); - - const getUnresolved = (uris) => - uris.map((uri) => !makeSelectClaimForUri(uri)(state) && uri).filter((uri) => uri !== false); - const getCanonical = (uris) => - uris - .map((uri) => makeSelectClaimForUri(uri)(state) && makeSelectClaimForUri(uri)(state).canonical_url) - .filter((uri) => Boolean(uri)); - - return { - commentorUris, - subscriptionUris, - unresolvedCommentors: getUnresolved(commentorUris), - unresolvedSubscriptions: getUnresolved(subscriptionUris), - canonicalCreator: getCanonical([props.creatorUri])[0], - canonicalCommentors: getCanonical(commentorUris), - canonicalSubscriptions: getCanonical(subscriptionUris), - showMature: selectShowMatureContent(state), - }; -}; - -export default withRouter(connect(select, { doResolveUris })(ChannelMentionSuggestions)); diff --git a/ui/component/channelMentionSuggestions/view.jsx b/ui/component/channelMentionSuggestions/view.jsx deleted file mode 100644 index bb04de507..000000000 --- a/ui/component/channelMentionSuggestions/view.jsx +++ /dev/null @@ -1,277 +0,0 @@ -// @flow -import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox'; -import { Form } from 'component/common/form'; -import { parseURI, regexInvalidURI } from 'util/lbryURI'; -import { SEARCH_OPTIONS } from 'constants/search'; -import * as KEYCODES from 'constants/keycodes'; -import ChannelMentionSuggestion from 'component/channelMentionSuggestion'; -import ChannelMentionTopSuggestion from 'component/channelMentionTopSuggestion'; -import React from 'react'; -import Spinner from 'component/spinner'; -import type { ElementRef } from 'react'; -import useLighthouse from 'effects/use-lighthouse'; - -const INPUT_DEBOUNCE_MS = 1000; -const LIGHTHOUSE_MIN_CHARACTERS = 3; - -type Props = { - inputRef: any, - mentionTerm: string, - noTopSuggestion?: boolean, - showMature: boolean, - creatorUri: string, - commentorUris: Array, - subscriptionUris: Array, - unresolvedSubscriptions: Array, - canonicalCreator: string, - canonicalCommentors: Array, - canonicalSubscriptions: Array, - doResolveUris: (Array) => void, - customSelectAction?: (string, number) => void, -}; - -export default function ChannelMentionSuggestions(props: Props) { - const { - unresolvedSubscriptions, - canonicalCreator, - creatorUri, - inputRef, - showMature, - noTopSuggestion, - mentionTerm, - doResolveUris, - customSelectAction, - } = props; - const comboboxInputRef: ElementRef = React.useRef(); - const comboboxListRef: ElementRef = React.useRef(); - - const mainEl = document.querySelector('.channel-mention__suggestions'); - - const [debouncedTerm, setDebouncedTerm] = React.useState(''); - const [mostSupported, setMostSupported] = React.useState(''); - const [canonicalResults, setCanonicalResults] = React.useState([]); - - const isRefFocused = (ref) => ref && ref.current === document.activeElement; - - const subscriptionUris = props.subscriptionUris.filter((uri) => uri !== creatorUri); - const canonicalSubscriptions = props.canonicalSubscriptions.filter((uri) => uri !== canonicalCreator); - const commentorUris = props.commentorUris.filter((uri) => uri !== creatorUri && !subscriptionUris.includes(uri)); - const canonicalCommentors = props.canonicalCommentors.filter( - (uri) => uri !== canonicalCreator && !canonicalSubscriptions.includes(uri) - ); - - const termToMatch = mentionTerm && mentionTerm.replace('@', '').toLowerCase(); - const allShownUris = [creatorUri, ...subscriptionUris, ...commentorUris]; - const allShownCanonical = [canonicalCreator, ...canonicalSubscriptions, ...canonicalCommentors]; - const possibleMatches = allShownUris.filter((uri) => { - try { - // yuck a try catch in a filter? - const { channelName } = parseURI(uri); - return channelName && channelName.toLowerCase().includes(termToMatch); - } catch (e) {} - }); - - const searchSize = 5; - const additionalOptions = { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS }; - const { results, loading } = useLighthouse(debouncedTerm, showMature, searchSize, additionalOptions, 0); - const stringifiedResults = JSON.stringify(results); - - const hasMinLength = mentionTerm && mentionTerm.length >= LIGHTHOUSE_MIN_CHARACTERS; - const isTyping = debouncedTerm !== mentionTerm; - const showPlaceholder = isTyping || loading; - - const isUriFromTermValid = !regexInvalidURI.test(mentionTerm.substring(1)); - - const handleSelect = React.useCallback( - (value, key) => { - if (customSelectAction) { - // Give them full results, as our resolved one might truncate the claimId. - customSelectAction(value || (results && results.find((r) => r.startsWith(value))) || '', Number(key)); - } - }, - [customSelectAction, results] - ); - - React.useEffect(() => { - const timer = setTimeout(() => { - if (isTyping) setDebouncedTerm(!hasMinLength ? '' : mentionTerm); - }, INPUT_DEBOUNCE_MS); - - return () => clearTimeout(timer); - }, [hasMinLength, isTyping, mentionTerm]); - - React.useEffect(() => { - if (!mainEl) return; - const header = document.querySelector('.header__navigation'); - - function handleReflow() { - const boxAtTopOfPage = header && mainEl.getBoundingClientRect().top <= header.offsetHeight; - const boxAtBottomOfPage = mainEl.getBoundingClientRect().bottom >= window.innerHeight; - - if (boxAtTopOfPage) { - mainEl.setAttribute('flow-bottom', ''); - } - if (mainEl.getAttribute('flow-bottom') !== null && boxAtBottomOfPage) { - mainEl.removeAttribute('flow-bottom'); - } - } - handleReflow(); - - window.addEventListener('scroll', handleReflow); - return () => window.removeEventListener('scroll', handleReflow); - }, [mainEl]); - - React.useEffect(() => { - if (!inputRef || !comboboxInputRef || !mentionTerm) return; - - function handleKeyDown(event) { - const { keyCode } = event; - const activeElement = document.activeElement; - - if (keyCode === KEYCODES.UP || keyCode === KEYCODES.DOWN) { - if (isRefFocused(comboboxInputRef)) { - const selectedId = activeElement && activeElement.getAttribute('aria-activedescendant'); - const selectedItem = selectedId && document.querySelector(`li[id="${selectedId}"]`); - if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } else { - // $FlowFixMe - comboboxInputRef.current.focus(); - } - } else { - if ((isRefFocused(comboboxInputRef) || isRefFocused(inputRef)) && keyCode === KEYCODES.TAB) { - event.preventDefault(); - const activeValue = activeElement && activeElement.getAttribute('value'); - - if (activeValue) { - handleSelect(activeValue, keyCode); - } else if (possibleMatches.length) { - // $FlowFixMe - const suggest = allShownCanonical.find((matchUri) => possibleMatches.find((uri) => uri.includes(matchUri))); - if (suggest) handleSelect(suggest, keyCode); - } else if (results) { - handleSelect(mentionTerm, keyCode); - } - } - if (isRefFocused(comboboxInputRef)) { - // $FlowFixMe - inputRef.current.focus(); - } - } - } - - window.addEventListener('keydown', handleKeyDown); - - return () => window.removeEventListener('keydown', handleKeyDown); - }, [allShownCanonical, handleSelect, inputRef, mentionTerm, possibleMatches, results]); - - React.useEffect(() => { - if (!stringifiedResults) return; - - const arrayResults = JSON.parse(stringifiedResults); - if (arrayResults && arrayResults.length > 0) { - // $FlowFixMe - doResolveUris(arrayResults).then((response) => { - try { - // $FlowFixMe - const canonical_urls = Object.values(response).map(({ canonical_url }) => canonical_url); - setCanonicalResults(canonical_urls); - } catch (e) {} - }); - } - }, [doResolveUris, stringifiedResults]); - - // Only resolve the subscriptions that match the mention term, instead of all - React.useEffect(() => { - if (isTyping) return; - - const urisToResolve = []; - subscriptionUris.map( - (uri) => - hasMinLength && - possibleMatches.includes(uri) && - unresolvedSubscriptions.includes(uri) && - urisToResolve.push(uri) - ); - - if (urisToResolve.length > 0) doResolveUris(urisToResolve); - }, [doResolveUris, hasMinLength, isTyping, possibleMatches, subscriptionUris, unresolvedSubscriptions]); - - const suggestionsRow = ( - label: string, - suggestions: Array, - canonical: Array, - hasSuggestionsBelow: boolean - ) => { - if (mentionTerm.length > 1 && suggestions !== results) { - suggestions = suggestions.filter((uri) => possibleMatches.includes(uri)); - } else if (suggestions === results) { - suggestions = suggestions.filter((uri) => !allShownUris.includes(uri)); - } - // $FlowFixMe - suggestions = suggestions - .map((matchUri) => canonical.find((uri) => matchUri.includes(uri))) - .filter((uri) => Boolean(uri)); - - if (canonical === canonicalResults) { - suggestions = suggestions.filter((uri) => uri !== mostSupported); - } - - return !suggestions.length ? null : ( - <> -
{label}
- {suggestions.map((uri) => ( - - ))} - {hasSuggestionsBelow &&
} - - ); - }; - - return isRefFocused(inputRef) || isRefFocused(comboboxInputRef) ? ( -
handleSelect(mentionTerm)}> - - - {mentionTerm && isUriFromTermValid && ( - - - {creatorUri && - suggestionsRow( - __('Creator'), - [creatorUri], - [canonicalCreator], - canonicalSubscriptions.length > 0 || commentorUris.length > 0 || !showPlaceholder - )} - {canonicalSubscriptions.length > 0 && - suggestionsRow( - __('Following'), - subscriptionUris, - canonicalSubscriptions, - commentorUris.length > 0 || !showPlaceholder - )} - {commentorUris.length > 0 && - suggestionsRow(__('From comments'), commentorUris, canonicalCommentors, !showPlaceholder)} - - {hasMinLength && - (showPlaceholder ? ( - - ) : ( - results && ( - <> - {!noTopSuggestion && ( - setMostSupported(winningUri)} - /> - )} - {suggestionsRow(__('From search'), results, canonicalResults, false)} - - ) - ))} - - - )} - -
- ) : null; -} diff --git a/ui/component/channelMentionTopSuggestion/index.js b/ui/component/channelMentionTopSuggestion/index.js deleted file mode 100644 index 2d21fda09..000000000 --- a/ui/component/channelMentionTopSuggestion/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import { connect } from 'react-redux'; -import { selectIsUriResolving } from 'redux/selectors/claims'; -import { doResolveUri } from 'redux/actions/claims'; -import { makeSelectWinningUriForQuery } from 'redux/selectors/search'; -import ChannelMentionTopSuggestion from './view'; - -const select = (state, props) => { - const uriFromQuery = `lbry://${props.query}`; - return { - uriFromQuery, - isResolvingUri: selectIsUriResolving(state, uriFromQuery), - winningUri: makeSelectWinningUriForQuery(props.query)(state), - }; -}; - -export default connect(select, { doResolveUri })(ChannelMentionTopSuggestion); diff --git a/ui/component/channelMentionTopSuggestion/view.jsx b/ui/component/channelMentionTopSuggestion/view.jsx deleted file mode 100644 index 6ea79c233..000000000 --- a/ui/component/channelMentionTopSuggestion/view.jsx +++ /dev/null @@ -1,49 +0,0 @@ -// @flow -import ChannelMentionSuggestion from 'component/channelMentionSuggestion'; -import LbcSymbol from 'component/common/lbc-symbol'; -import React from 'react'; - -type Props = { - uriFromQuery: string, - winningUri: string, - isResolvingUri: boolean, - shownUris: Array, - setMostSupported: (string) => void, - doResolveUri: (string) => void, -}; - -export default function ChannelMentionTopSuggestion(props: Props) { - const { uriFromQuery, winningUri, isResolvingUri, shownUris, setMostSupported, doResolveUri } = props; - - React.useEffect(() => { - if (uriFromQuery) doResolveUri(uriFromQuery); - }, [doResolveUri, uriFromQuery]); - - React.useEffect(() => { - if (winningUri) setMostSupported(winningUri); - }, [setMostSupported, winningUri]); - - if (isResolvingUri) { - return ( -
-
- -
-
-
-
-
-
- ); - } - - return !winningUri || shownUris.includes(winningUri) ? null : ( - <> -
- -
- -
- - ); -} diff --git a/ui/component/comment/view.jsx b/ui/component/comment/view.jsx index 13a997128..b33af33d8 100644 --- a/ui/component/comment/view.jsx +++ b/ui/component/comment/view.jsx @@ -26,6 +26,8 @@ import CommentCreate from 'component/commentCreate'; import CommentMenuList from 'component/commentMenuList'; import UriIndicator from 'component/uriIndicator'; import CreditAmount from 'component/common/credit-amount'; +import OptimizedImage from 'component/optimizedImage'; +import { parseSticker } from 'util/comments'; const AUTO_EXPAND_ALL_REPLIES = false; @@ -130,6 +132,7 @@ function Comment(props: Props) { const totalLikesAndDislikes = likesCount + dislikesCount; const slimedToDeath = totalLikesAndDislikes >= 5 && dislikesCount / totalLikesAndDislikes > 0.8; const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri; + const stickerFromMessage = parseSticker(message); let channelOwnerOfContent; try { @@ -324,6 +327,10 @@ function Comment(props: Props) {
setDisplayDeadComment(true)} className="comment__dead"> {__('This comment was slimed to death.')}
+ ) : stickerFromMessage ? ( +
+ +
) : editedMessage.length >= LENGTH_TO_COLLAPSE ? ( 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 ( +
+ + ); + })} +
+
+
+
+ ); +} diff --git a/ui/component/commentCreate/index.js b/ui/component/commentCreate/index.js index ff760c3e3..add51ccae 100644 --- a/ui/component/commentCreate/index.js +++ b/ui/component/commentCreate/index.js @@ -6,13 +6,13 @@ import { selectFetchingMyChannels, makeSelectTagInClaimOrChannelForUri, } from 'redux/selectors/claims'; -import { doSendTip } from 'redux/actions/wallet'; +import { CommentCreate } from './view'; +import { DISABLE_SUPPORT_TAG } from 'constants/tags'; import { doCommentCreate, doFetchCreatorSettings, doCommentById } from 'redux/actions/comments'; +import { doSendTip } from 'redux/actions/wallet'; +import { doToast } from 'redux/actions/notifications'; import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectSettingsByChannelId } from 'redux/selectors/comments'; -import { CommentCreate } from './view'; -import { doToast } from 'redux/actions/notifications'; -import { DISABLE_SUPPORT_TAG } from 'constants/tags'; const select = (state, props) => { const claim = selectClaimForUri(state, props.uri); @@ -28,12 +28,12 @@ const select = (state, props) => { }; const perform = (dispatch, ownProps) => ({ - createComment: (comment, claimId, parentId, txid, payment_intent_id, environment) => - dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri, txid, payment_intent_id, environment)), - sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)), - doToast: (options) => dispatch(doToast(options)), + createComment: (comment, claimId, parentId, txid, payment_intent_id, environment, sticker) => + dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri, txid, payment_intent_id, environment, sticker)), doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)), + doToast: (options) => dispatch(doToast(options)), fetchComment: (commentId) => dispatch(doCommentById(commentId, false)), + sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)), }); export default connect(select, perform)(CommentCreate); diff --git a/ui/component/commentCreate/sticker-selector.jsx b/ui/component/commentCreate/sticker-selector.jsx new file mode 100644 index 000000000..c82aa9df8 --- /dev/null +++ b/ui/component/commentCreate/sticker-selector.jsx @@ -0,0 +1,94 @@ +// @flow +import 'scss/component/_sticker-selector.scss'; +import { FREE_GLOBAL_STICKERS, PAID_GLOBAL_STICKERS } from 'constants/stickers'; +import * as ICONS from 'constants/icons'; +import Button from 'component/button'; +import CreditAmount from 'component/common/credit-amount'; +import OptimizedImage from 'component/optimizedImage'; +import React from 'react'; + +const buildStickerSideLink = (section: string, icon: string) => ({ section, icon }); + +const STICKER_SIDE_LINKS = [ + buildStickerSideLink(__('Free'), ICONS.TAG), + buildStickerSideLink(__('Tips'), ICONS.FINANCE), + // Future work may include Channel, Subscriptions, ... +]; + +type Props = { claimIsMine: boolean, onSelect: (any) => void }; + +export default function StickerSelector(props: Props) { + const { claimIsMine, onSelect } = props; + + function scrollToStickerSection(section: string) { + const listBodyEl = document.querySelector('.stickerSelector__listBody'); + const sectionToScroll = document.getElementById(section); + + if (listBodyEl && sectionToScroll) { + // $FlowFixMe + listBodyEl.scrollTo({ + top: sectionToScroll.offsetTop - sectionToScroll.getBoundingClientRect().height * 2, + behavior: 'smooth', + }); + } + } + + const getListRow = (rowTitle: string, rowStickers: any) => ( +
+
+ {rowTitle} +
+
+ {rowStickers.map((sticker) => ( + + ))} +
+
+ ); + + return ( +
+
+
{__('Stickers')}
+
+ +
+
+ {getListRow(__('Free'), FREE_GLOBAL_STICKERS)} + {!claimIsMine && getListRow(__('Tips'), PAID_GLOBAL_STICKERS)} +
+ +
+
    + {STICKER_SIDE_LINKS.map( + (linkProps) => + ((claimIsMine && linkProps.section !== 'Tips') || !claimIsMine) && ( +
  • +
  • + ) + )} +
+
+
+
+ ); +} diff --git a/ui/component/commentCreate/view.jsx b/ui/component/commentCreate/view.jsx index f03b0ec66..a29f039f9 100644 --- a/ui/component/commentCreate/view.jsx +++ b/ui/component/commentCreate/view.jsx @@ -1,4 +1,8 @@ // @flow + +import 'scss/component/_comment-create.scss'; + +import { buildValidSticker } from 'util/comments'; import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field'; import { FormField, Form } from 'component/common/form'; import { getChannelIdFromClaim } from 'util/claim'; @@ -8,158 +12,142 @@ import * as ICONS from 'constants/icons'; import * as KEYCODES from 'constants/keycodes'; import * as PAGES from 'constants/pages'; import Button from 'component/button'; -import ChannelMentionSuggestions from 'component/channelMentionSuggestions'; import ChannelThumbnail from 'component/channelThumbnail'; import classnames from 'classnames'; import CreditAmount from 'component/common/credit-amount'; +import EmoteSelector from './emote-selector'; import Empty from 'component/common/empty'; +import FilePrice from 'component/filePrice'; import I18nMessage from 'component/i18nMessage'; import Icon from 'component/common/icon'; +import OptimizedImage from 'component/optimizedImage'; import React from 'react'; import SelectChannel from 'component/selectChannel'; +import StickerSelector from './sticker-selector'; import type { ElementRef } from 'react'; import UriIndicator from 'component/uriIndicator'; import usePersistedState from 'effects/use-persisted-state'; import WalletTipAmountSelector from 'component/walletTipAmountSelector'; import { getStripeEnvironment } from 'util/stripe'; -let stripeEnvironment = getStripeEnvironment(); +const stripeEnvironment = getStripeEnvironment(); const TAB_FIAT = 'TabFiat'; const TAB_LBC = 'TabLBC'; -const MENTION_DEBOUNCE_MS = 100; + +// for sendCashTip REMOVE +// type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string }; +// type UserParams = { activeChannelName: ?string, activeChannelId: ?string }; type Props = { - uri: string, - claim: StreamClaim, - hasChannels: boolean, - isNested: boolean, - isFetchingChannels: boolean, - parentId: string, - isReply: boolean, activeChannel: string, activeChannelClaim: ?ChannelClaim, bottom: boolean, - embed?: boolean, + hasChannels: boolean, + claim: StreamClaim, claimIsMine: boolean, - supportDisabled: boolean, + isFetchingChannels: boolean, + isNested: boolean, + isReply: boolean, + parentId: string, settingsByChannelId: { [channelId: string]: PerChannelSettings }, shouldFetchComment: boolean, - doToast: ({ message: string }) => void, - createComment: (string, string, string, ?string, ?string, ?string) => Promise, - onDoneReplying?: () => void, - onCancelReplying?: () => void, - toast: (string) => void, - sendTip: ({}, (any) => void, (any) => void) => void, + supportDisabled: boolean, + uri: string, + createComment: (string, string, string, ?string, ?string, ?string, ?boolean) => Promise, doFetchCreatorSettings: (channelId: string) => Promise, - setQuickReply: (any) => void, + doToast: ({ message: string }) => void, fetchComment: (commentId: string) => Promise, + onCancelReplying?: () => void, + onDoneReplying?: () => void, + sendTip: ({}, (any) => void, (any) => void) => void, + setQuickReply: (any) => void, + toast: (string) => void, }; export function CommentCreate(props: Props) { const { - uri, - claim, - hasChannels, - isNested, - isFetchingChannels, - isReply, - parentId, activeChannelClaim, bottom, + hasChannels, + claim, claimIsMine, + isFetchingChannels, + isNested, + isReply, + parentId, settingsByChannelId, - supportDisabled, shouldFetchComment, - doToast, + supportDisabled, createComment, - onDoneReplying, - onCancelReplying, - sendTip, doFetchCreatorSettings, - setQuickReply, + doToast, fetchComment, + onCancelReplying, + onDoneReplying, + sendTip, + setQuickReply, } = props; + const formFieldRef: ElementRef = React.useRef(); - const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input; - const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart; const buttonRef: ElementRef = React.useRef(); + const { push, location: { pathname }, } = useHistory(); - const [isSubmitting, setIsSubmitting] = React.useState(false); + const [isSubmitting, setSubmitting] = React.useState(false); const [commentFailure, setCommentFailure] = React.useState(false); const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined }); const [isSupportComment, setIsSupportComment] = React.useState(); - const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState(); + const [isReviewingSupportComment, setReviewingSupportComment] = React.useState(); + const [isReviewingStickerComment, setReviewingStickerComment] = React.useState(); + const [selectedSticker, setSelectedSticker] = React.useState(); const [tipAmount, setTipAmount] = React.useState(1); + const [convertedAmount, setConvertedAmount] = React.useState(); const [commentValue, setCommentValue] = React.useState(''); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); - const [activeTab, setActiveTab] = React.useState(''); + const [stickerSelector, setStickerSelector] = React.useState(); + const [activeTab, setActiveTab] = React.useState(); const [tipError, setTipError] = React.useState(); const [deletedComment, setDeletedComment] = React.useState(false); - const [pauseQuickSend, setPauseQuickSend] = React.useState(false); - const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState(); - - const selectedMentionIndex = - commentValue.indexOf('@', selectionIndex) === selectionIndex - ? commentValue.indexOf('@', selectionIndex) - : commentValue.lastIndexOf('@', selectionIndex); - const modifierIndex = commentValue.indexOf(':', selectedMentionIndex); - const spaceIndex = commentValue.indexOf(' ', selectedMentionIndex); - const mentionLengthIndex = - modifierIndex >= 0 && (spaceIndex === -1 || modifierIndex < spaceIndex) - ? modifierIndex - : spaceIndex >= 0 && (modifierIndex === -1 || spaceIndex < modifierIndex) - ? spaceIndex - : commentValue.length; - const channelMention = - selectedMentionIndex >= 0 && selectionIndex <= mentionLengthIndex - ? commentValue.substring(selectedMentionIndex, mentionLengthIndex) - : ''; + const [showEmotes, setShowEmotes] = React.useState(false); + const [disableReviewButton, setDisableReviewButton] = React.useState(); + const [exchangeRate, setExchangeRate] = React.useState(); + const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(undefined); const claimId = claim && claim.claim_id; - const signingChannel = (claim && claim.signing_channel) || claim; - const channelUri = signingChannel && signingChannel.permanent_url; const charCount = commentValue ? commentValue.length : 0; - const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend; + const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length; const channelId = getChannelIdFromClaim(claim); const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; const minTip = (channelSettings && channelSettings.min_tip_amount_comment) || 0; const minAmount = minTip || minSuper || 0; const minAmountMet = minAmount === 0 || tipAmount >= minAmount; + const stickerPrice = selectedSticker && selectedSticker.price; const minAmountRef = React.useRef(minAmount); minAmountRef.current = minAmount; - const MinAmountNotice = minAmount ? ( -
- }}> - {minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''} - - -
- ) : null; - // ************************************************************************** // Functions // ************************************************************************** + function handleSelectSticker(sticker: any) { + // $FlowFixMe + setSelectedSticker(sticker); + setReviewingStickerComment(true); + setTipAmount(sticker.price || 0); + setStickerSelector(false); + + if (sticker.price && sticker.price > 0) { + setActiveTab(TAB_LBC); + setIsSupportComment(true); + } + } + function handleCommentChange(event) { let commentValue; if (isReply) { @@ -171,19 +159,6 @@ export function CommentCreate(props: Props) { setCommentValue(commentValue); } - function handleSelectMention(mentionValue, key) { - let newMentionValue = mentionValue.replace('lbry://', ''); - if (newMentionValue.includes('#')) newMentionValue = newMentionValue.replace('#', ':'); - - setCommentValue( - commentValue.substring(0, selectedMentionIndex) + - `${newMentionValue}` + - (commentValue.length > mentionLengthIndex + 1 - ? commentValue.substring(mentionLengthIndex, commentValue.length) - : ' ') - ); - } - function altEnterListener(e: SyntheticKeyboardEvent<*>) { if ((e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) { e.preventDefault(); @@ -199,16 +174,8 @@ export function CommentCreate(props: Props) { window.removeEventListener('keydown', altEnterListener); } - function handleSubmit() { - if (activeChannelClaim && commentValue.length) { - handleCreateComment(); - } - } - function handleSupportComment() { - if (!activeChannelClaim) { - return; - } + if (!activeChannelClaim) return; if (!channelId) { doToast({ @@ -236,7 +203,7 @@ export function CommentCreate(props: Props) { message: __('The creator just updated the minimum setting. Please revise or double-check your tip amount.'), isError: true, }); - setIsReviewingSupportComment(false); + setReviewingSupportComment(false); return; } @@ -245,33 +212,18 @@ export function CommentCreate(props: Props) { } function doSubmitTip() { - if (!activeChannelClaim) { - return; - } + if (!activeChannelClaim || isSubmitting) return; - const params = { - amount: tipAmount, - claim_id: claimId, - channel_id: activeChannelClaim.claim_id, - }; + setSubmitting(true); - const activeChannelName = activeChannelClaim && activeChannelClaim.name; - const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id; + const params = { amount: tipAmount, claim_id: claimId, channel_id: activeChannelClaim.claim_id }; + // FIAT ONLY - REMOVE + // const activeChannelName = activeChannelClaim && activeChannelClaim.name; + // const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id; // setup variables for tip API - let channelClaimId, tipChannelName; - // if there is a signing channel it's on a file - if (claim.signing_channel) { - channelClaimId = claim.signing_channel.claim_id; - tipChannelName = claim.signing_channel.name; - - // otherwise it's on the channel page - } else { - channelClaimId = claim.claim_id; - tipChannelName = claim.name; - } - - setIsSubmitting(true); + // const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id; + const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name; if (activeTab === TAB_LBC) { // call sendTip and then run the callback from the response @@ -286,72 +238,34 @@ export function CommentCreate(props: Props) { }, 1500); doToast({ - message: __( - "You sent %tipAmount% LBRY Credits as a tip to %tipChannelName%, I'm sure they appreciate it!", - { - tipAmount: tipAmount, // force show decimal places - tipChannelName, - } - ), + message: __("You sent %tipAmount% Credits as a tip to %tipChannelName%, I'm sure they appreciate it!", { + tipAmount: tipAmount, // force show decimal places + tipChannelName, + }), }); setSuccessTip({ txid, tipAmount }); }, () => { // reset the frontend so people can send a new comment - setIsSubmitting(false); + setSubmitting(false); } ); } else { - const sourceClaimId = claim.claim_id; - const roundedAmount = Math.round(tipAmount * 100) / 100; - - Lbryio.call( - 'customer', - 'tip', - { - // round to deal with floating point precision - amount: Math.round(100 * roundedAmount), // convert from dollars to cents - creator_channel_name: tipChannelName, // creator_channel_name - creator_channel_claim_id: channelClaimId, - tipper_channel_name: activeChannelName, - tipper_channel_claim_id: activeChannelId, - currency: 'USD', - anonymous: false, - source_claim_id: sourceClaimId, - environment: stripeEnvironment, - }, - 'post' - ) - .then((customerTipResponse) => { - const paymentIntendId = customerTipResponse.payment_intent_id; - - handleCreateComment(null, paymentIntendId, stripeEnvironment); - - setCommentValue(''); - setIsReviewingSupportComment(false); - setIsSupportComment(false); - setCommentFailure(false); - setIsSubmitting(false); - - doToast({ - message: __("You sent $%formattedAmount% as a tip to %tipChannelName%, I'm sure they appreciate it!", { - formattedAmount: roundedAmount.toFixed(2), // force show decimal places - tipChannelName, - }), - }); - - // handleCreateComment(null); - }) - .catch((error) => { - doToast({ - message: - error.message !== 'payment intent failed to confirm' - ? error.message - : 'Sorry, there was an error in processing your payment!', - isError: true, - }); - }); + // No cash tips - REMOVE + // const tipParams: TipParams = { tipAmount: Math.round(tipAmount * 100) / 100, tipChannelName, channelClaimId }; + // const userParams: UserParams = { activeChannelName, activeChannelId }; + // sendCashTip(tipParams, userParams, claim.claim_id, stripeEnvironment, (customerTipResponse) => { + // const { payment_intent_id } = customerTipResponse; + // + // handleCreateComment(null, payment_intent_id, stripeEnvironment); + // + // setCommentValue(''); + // setReviewingSupportComment(false); + // setIsSupportComment(false); + // setCommentFailure(false); + // setSubmitting(false); + // }); } } @@ -362,16 +276,21 @@ export function CommentCreate(props: Props) { * @param {string} [environment] Optional environment for Stripe (test|live) */ function handleCreateComment(txid, payment_intent_id, environment) { - setIsSubmitting(true); + if (isSubmitting) return; - createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment) + setShowEmotes(false); + setSubmitting(true); + + const stickerValue = selectedSticker && buildValidSticker(selectedSticker.name); + + createComment(stickerValue || commentValue, claimId, parentId, txid, payment_intent_id, environment, !!stickerValue) .then((res) => { - setIsSubmitting(false); + setSubmitting(false); if (setQuickReply) setQuickReply(res); if (res && res.signature) { - setCommentValue(''); - setIsReviewingSupportComment(false); + if (!stickerValue) setCommentValue(''); + setReviewingSupportComment(false); setIsSupportComment(false); setCommentFailure(false); @@ -381,7 +300,7 @@ export function CommentCreate(props: Props) { } }) .catch(() => { - setIsSubmitting(false); + setSubmitting(false); setCommentFailure(true); if (channelId) { @@ -412,22 +331,72 @@ export function CommentCreate(props: Props) { } }, [fetchComment, shouldFetchComment, parentId]); - // Debounce for disabling the submit button when mentioning a user with Enter - // so that the comment isn't sent at the same time + // Stickers: Get LBC-USD exchange rate if hasn't yet and selected a paid sticker React.useEffect(() => { - const timer = setTimeout(() => { - if (pauseQuickSend) { - setPauseQuickSend(false); - } - }, MENTION_DEBOUNCE_MS); + if (stickerPrice && !exchangeRate) Lbryio.getExchangeRates().then(({ LBC_USD }) => setExchangeRate(LBC_USD)); + }, [exchangeRate, stickerPrice]); - return () => clearTimeout(timer); - }, [pauseQuickSend]); + // 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 || !stickerSelector || canReceiveFiatTip !== undefined) return; + + const channelClaimId = claim.signing_channel ? claim.signing_channel.claim_id : claim.claim_id; + const tipChannelName = claim.signing_channel ? claim.signing_channel.name : claim.name; + + 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, claim.claim_id, claim.name, claim.signing_channel, stickerSelector]); + + // LIVESTREAM ONLY - REMOVE + // 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 = e.target.attributes['term']; + // + // if (((isLivestream && !isTyping) || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) { + // e.preventDefault(); + // buttonRef.current.click(); + // } + // } + // } + // + // window.addEventListener('keydown', altEnterListener); + // + // // removes the listener so it doesn't cause problems elsewhere in the app + // return () => { + // window.removeEventListener('keydown', altEnterListener); + // }; + // }, [isLivestream]); // ************************************************************************** // Render // ************************************************************************** + const getActionButton = (title: string, label?: string, icon: string, handleClick: () => void) => ( +
- - ); - } - - return ( - - {!advancedEditor && ( - - )} - -
{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}
- - - } - quickActionLabel={isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')} - quickActionHandler={() => setAdvancedEditor(!advancedEditor)} - onFocus={onTextareaFocus} - onBlur={onTextareaBlur} - placeholder={__('Say something about this...')} - value={commentValue} - charCount={charCount} - onChange={handleCommentChange} - autoFocus={isReply} - textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT} - /> - {isSupportComment && ( - setTipAmount(amount)} - /> - )} -
- {isSupportComment ? ( - <> -
); diff --git a/ui/component/common/credit-amount.jsx b/ui/component/common/credit-amount.jsx index 99136699e..225842e6d 100644 --- a/ui/component/common/credit-amount.jsx +++ b/ui/component/common/credit-amount.jsx @@ -1,102 +1,112 @@ // @flow -import React from 'react'; +import { formatCredits, formatFullPrice } from 'util/format-credits'; import classnames from 'classnames'; import LbcSymbol from 'component/common/lbc-symbol'; -import { formatCredits, formatFullPrice } from 'util/format-credits'; +import React from 'react'; type Props = { - amount: number, + amount?: number, + className?: string, + customAmounts?: { amountFiat: number, amountLBC: number }, + fee?: boolean, + isEstimate?: boolean, + isFiat?: boolean, + noFormat?: boolean, precision: number, showFree: boolean, showFullPrice: boolean, - showPlus: boolean, - isEstimate?: boolean, showLBC?: boolean, - fee?: boolean, - className?: string, - noFormat?: boolean, + showPlus: boolean, size?: number, superChat?: boolean, superChatLight?: boolean, - isFiat?: boolean, }; class CreditAmount extends React.PureComponent { static defaultProps = { + noFormat: false, precision: 2, showFree: false, showFullPrice: false, - showPlus: false, showLBC: true, - noFormat: false, + showPlus: false, }; render() { const { amount, - precision, - showFullPrice, - showFree, - showPlus, - isEstimate, - fee, - showLBC, className, + customAmounts, + fee, + isEstimate, + isFiat, noFormat, + precision, + showFree, + showFullPrice, + showLBC, + showPlus, size, superChat, superChatLight, - isFiat, } = this.props; const minimumRenderableAmount = 10 ** (-1 * precision); // return null, otherwise it will try and convert undefined to a string - if (amount === undefined) { - return null; - } - const fullPrice = formatFullPrice(amount, 2); - const isFree = parseFloat(amount) === 0; + if (amount === undefined && customAmounts === undefined) return null; - let formattedAmount; - if (showFullPrice) { - formattedAmount = fullPrice; - } else { - formattedAmount = - amount > 0 && amount < minimumRenderableAmount - ? `<${minimumRenderableAmount}` - : formatCredits(amount, precision, true); - } + function getAmountText(amount: number, isFiat?: boolean) { + const fullPrice = formatFullPrice(amount, 2); + const isFree = parseFloat(amount) === 0; + let formattedAmount; - let amountText; - if (showFree && isFree) { - amountText = __('Free'); - } else { - amountText = noFormat ? amount : formattedAmount; - - if (showPlus && amount > 0) { - amountText = `+${amountText}`; + if (showFullPrice) { + formattedAmount = fullPrice; + } else { + formattedAmount = + amount > 0 && amount < minimumRenderableAmount + ? `<${minimumRenderableAmount}` + : formatCredits(amount, precision, true); } - if (showLBC && !isFiat) { - amountText = ; - } else if (showLBC && isFiat) { - amountText =

${(Math.round(Number(amountText) * 100) / 100).toFixed(2)}

; - } + if (showFree && isFree) { + return __('Free'); + } else { + let amountText = noFormat ? amount : formattedAmount; - if (fee) { - amountText = __('%amount% fee', { amount: amountText }); + if (showPlus && amount > 0) { + amountText = `+${amountText}`; + } + + if (showLBC && !isFiat) { + amountText = ; + } else if (showLBC && isFiat) { + amountText =

${(Math.round(Number(amountText) * 100) / 100).toFixed(2)}

; + } + + if (fee) { + amountText = __('%amount% fee', { amount: amountText }); + } + + return amountText; } } return ( - {amountText} + {customAmounts + ? Object.values(customAmounts).map((amount, index) => ( + + {getAmountText(Number(amount), !index)} + + )) + : amount && {getAmountText(amount, isFiat)}} {isEstimate ? ( diff --git a/ui/component/common/form-components/form-field.jsx b/ui/component/common/form-components/form-field.jsx index 83e01f85d..9dac1df87 100644 --- a/ui/component/common/form-components/form-field.jsx +++ b/ui/component/common/form-components/form-field.jsx @@ -1,60 +1,53 @@ // @flow -import type { ElementRef, Node } from 'react'; +import 'easymde/dist/easymde.min.css'; +import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field'; +import { openEditorMenu, stopContextMenu } from 'util/context-menu'; +import * as ICONS from 'constants/icons'; +import Button from 'component/button'; +import MarkdownPreview from 'component/common/markdown-preview'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; import SimpleMDE from 'react-simplemde-editor'; -import MarkdownPreview from 'component/common/markdown-preview'; -import { openEditorMenu, stopContextMenu } from 'util/context-menu'; -import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field'; -import 'easymde/dist/easymde.min.css'; -import Button from 'component/button'; -import emoji from 'emoji-dictionary'; - -const QUICK_EMOJIS = [ - emoji.getUnicode('rocket'), - emoji.getUnicode('jeans'), - emoji.getUnicode('fire'), - emoji.getUnicode('heart'), - emoji.getUnicode('open_mouth'), -]; +import TextareaWithSuggestions from 'component/textareaWithSuggestions'; +import type { ElementRef, Node } from 'react'; type Props = { - name: string, - label?: string | Node, - render?: () => React$Node, - prefix?: string, - postfix?: string, - error?: string | boolean, - helper?: string | React$Node, - type?: string, - onChange?: (any) => any, - defaultValue?: string | number, - placeholder?: string | number, - children?: React$Node, - stretch?: boolean, affixClass?: string, // class applied to prefix/postfix label autoFocus?: boolean, - labelOnLeft: boolean, - inputButton?: React$Node, blockWrap: boolean, charCount?: number, - textAreaMaxLength?: number, - range?: number, - min?: number, - max?: number, - quickActionLabel?: string, - quickActionHandler?: (any) => any, + children?: React$Node, + defaultValue?: string | number, disabled?: boolean, - onChange: (any) => void, - value?: string | number, + error?: string | boolean, + helper?: string | React$Node, + hideSuggestions?: boolean, + inputButton?: React$Node, + isLivestream?: boolean, + label?: string | Node, + labelOnLeft: boolean, + max?: number, + min?: number, + name: string, noEmojis?: boolean, + placeholder?: string | number, + postfix?: string, + prefix?: string, + quickActionLabel?: string, + range?: number, + readOnly?: boolean, + stretch?: boolean, + textAreaMaxLength?: number, + type?: string, + value?: string | number, + onChange?: (any) => any, + openEmoteMenu?: () => void, + quickActionHandler?: (any) => any, + render?: () => React$Node, }; export class FormField extends React.PureComponent { - static defaultProps = { - labelOnLeft: false, - blockWrap: true, - }; + static defaultProps = { labelOnLeft: false, blockWrap: true }; input: { current: ElementRef }; @@ -67,36 +60,48 @@ export class FormField extends React.PureComponent { const { autoFocus } = this.props; const input = this.input.current; - if (input && autoFocus) { - input.focus(); - } + if (input && autoFocus) input.focus(); } render() { const { - render, - label, - prefix, - postfix, - error, - helper, - name, - type, - children, - stretch, affixClass, autoFocus, - inputButton, - labelOnLeft, blockWrap, charCount, - textAreaMaxLength, - quickActionLabel, - quickActionHandler, + children, + error, + helper, + hideSuggestions, + inputButton, + isLivestream, + label, + labelOnLeft, + name, noEmojis, + postfix, + prefix, + quickActionLabel, + stretch, + textAreaMaxLength, + type, + openEmoteMenu, + quickActionHandler, + render, ...inputProps } = this.props; + const errorMessage = typeof error === 'object' ? error.message : error; + + // Ideally, the character count should (and can) be appended to the + // SimpleMDE's "options::status" bar. However, I couldn't figure out how + // to pass the current value to it's callback, nor query the current + // text length from the callback. So, we'll use our own widget. + const hasCharCount = charCount !== undefined && charCount >= 0; + const countInfo = hasCharCount && textAreaMaxLength !== undefined && ( + {`${charCount || '0'}/${textAreaMaxLength}`} + ); + const Wrapper = blockWrap ? ({ children: innerChildren }) => {innerChildren} : ({ children: innerChildren }) => {innerChildren}; @@ -108,207 +113,177 @@ export class FormField extends React.PureComponent { ) : null; - let input; - if (type) { - if (type === 'radio') { - input = ( - - - - - ); - } else if (type === 'checkbox') { - input = ( -
- - -
- ); - } else if (type === 'range') { - input = ( -
- - -
- ); - } else if (type === 'select') { - input = ( - - {(label || errorMessage) && ( - - )} - - - ); - } else if (type === 'select-tiny') { - input = ( - - {(label || errorMessage) && ( - - )} - - - ); - } else if (type === 'markdown') { - const handleEvents = { - contextmenu: openEditorMenu, - }; + const inputSimple = (type: string) => ( + <> + + + + ); - const getInstance = (editor) => { - // SimpleMDE max char check - editor.codemirror.on('beforeChange', (instance, changes) => { - if (textAreaMaxLength && changes.update) { - var str = changes.text.join('\n'); - var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from)); - if (delta <= 0) { - return; - } - delta = instance.getValue().length + delta - textAreaMaxLength; - if (delta > 0) { - str = str.substr(0, str.length - delta); - changes.update(changes.from, changes.to, str.split('\n')); - } - } - }); + const inputSelect = (selectClass: string) => ( + + {(label || errorMessage) && ( + + )} + + + ); - // "Create Link (Ctrl-K)": highlight URL instead of label: - editor.codemirror.on('changes', (instance, changes) => { - try { - // Grab the last change from the buffered list. I assume the - // buffered one ('changes', instead of 'change') is more efficient, - // and that "Create Link" will always end up last in the list. - const lastChange = changes[changes.length - 1]; - if (lastChange.origin === '+input') { - // https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765 - const EASYMDE_URL_PLACEHOLDER = '(https://)'; + const input = () => { + switch (type) { + case 'radio': + return {inputSimple('radio')}; + case 'checkbox': + return
{inputSimple('checkbox')}
; + case 'range': + return
{inputSimple('range')}
; + case 'select': + return inputSelect(''); + case 'select-tiny': + return inputSelect('select--slim'); + case 'markdown': + const handleEvents = { contextmenu: openEditorMenu }; - // The URL placeholder is always placed last, so just look at the - // last text in the array to also cover the multi-line case: - const urlLineText = lastChange.text[lastChange.text.length - 1]; + const getInstance = (editor) => { + // SimpleMDE max char check + editor.codemirror.on('beforeChange', (instance, changes) => { + if (textAreaMaxLength && changes.update) { + var str = changes.text.join('\n'); + var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from)); - if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) { - const from = lastChange.from; - const to = lastChange.to; - const isSelectionMultiline = lastChange.text.length > 1; - const baseIndex = isSelectionMultiline ? 0 : from.ch; + if (delta <= 0) return; - // Everything works fine for the [Ctrl-K] case, but for the - // [Button] case, this handler happens before the original - // code, thus our change got wiped out. - // Add a small delay to handle that case. - setTimeout(() => { - instance.setSelection( - { line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 }, - { line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') } - ); - }, 25); + delta = instance.getValue().length + delta - textAreaMaxLength; + if (delta > 0) { + str = str.substr(0, str.length - delta); + changes.update(changes.from, changes.to, str.split('\n')); } } - } catch (err) { - // Do nothing (revert to original behavior) - } - }); - }; + }); - // Ideally, the character count should (and can) be appended to the - // SimpleMDE's "options::status" bar. However, I couldn't figure out how - // to pass the current value to it's callback, nor query the current - // text length from the callback. So, we'll use our own widget. - const hasCharCount = charCount !== undefined && charCount >= 0; - const countInfo = hasCharCount && textAreaMaxLength !== undefined && ( - {`${charCount || '0'}/${textAreaMaxLength}`} - ); + // "Create Link (Ctrl-K)": highlight URL instead of label: + editor.codemirror.on('changes', (instance, changes) => { + try { + // Grab the last change from the buffered list. I assume the + // buffered one ('changes', instead of 'change') is more efficient, + // and that "Create Link" will always end up last in the list. + const lastChange = changes[changes.length - 1]; + if (lastChange.origin === '+input') { + // https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765 + const EASYMDE_URL_PLACEHOLDER = '(https://)'; - input = ( -
+ // The URL placeholder is always placed last, so just look at the + // last text in the array to also cover the multi-line case: + const urlLineText = lastChange.text[lastChange.text.length - 1]; + + if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) { + const from = lastChange.from; + const to = lastChange.to; + const isSelectionMultiline = lastChange.text.length > 1; + const baseIndex = isSelectionMultiline ? 0 : from.ch; + + // Everything works fine for the [Ctrl-K] case, but for the + // [Button] case, this handler happens before the original + // code, thus our change got wiped out. + // Add a small delay to handle that case. + setTimeout(() => { + instance.setSelection( + { line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 }, + { line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') } + ); + }, 25); + } + } + } catch (e) {} // Do nothing (revert to original behavior) + }); + }; + + return ( +
+ +
+
+ +
+ {quickAction} +
+ ; + return ReactDOMServer.renderToString(preview); + }, + }} + /> + {countInfo} +
+
+ ); + case 'textarea': + return ( -
-
+ {(label || quickAction) && ( +
-
- {quickAction} -
- ; - return ReactDOMServer.renderToString(preview); - }, - }} - /> - {countInfo} - -
- ); - } else if (type === 'textarea') { - const hasCharCount = charCount !== undefined && charCount >= 0; - const countInfo = hasCharCount && textAreaMaxLength !== undefined && ( - {`${charCount || '0'}/${textAreaMaxLength}`} - ); - input = ( - - {(label || quickAction) && ( -
-
- -
- {quickAction} -
- )} -