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} -
- )} -