diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d470fd4..39f6715a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added "Shuffle" option on Lists ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921)) - Added Play Next/Previous buttons (with shortcuts SHIFT+N/SHIFT+P) ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921)) - Added separate control for autoplay next on video player ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921)) +- Added Channel Mention selection ability while creating a comment ([#7151](https://github.com/lbryio/lbry-desktop/pull/7151)) ### Changed - Changing the supported language from Filipino to Tagalog _community pr!_ ([#6951](https://github.com/lbryio/lbry-desktop/pull/6951)) diff --git a/ui/component/channelMentionSuggestion/index.js b/ui/component/channelMentionSuggestion/index.js new file mode 100644 index 000000000..40b1a92ae --- /dev/null +++ b/ui/component/channelMentionSuggestion/index.js @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; +import { makeSelectClaimForUri, makeSelectIsUriResolving } from 'lbry-redux'; +import ChannelMentionSuggestion from './view'; + +const select = (state, props) => ({ + claim: makeSelectClaimForUri(props.uri)(state), + isResolvingUri: makeSelectIsUriResolving(props.uri)(state), +}); + +export default connect(select)(ChannelMentionSuggestion); diff --git a/ui/component/channelMentionSuggestion/view.jsx b/ui/component/channelMentionSuggestion/view.jsx new file mode 100644 index 000000000..9d7a2afdc --- /dev/null +++ b/ui/component/channelMentionSuggestion/view.jsx @@ -0,0 +1,32 @@ +// @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 new file mode 100644 index 000000000..c5ba9712d --- /dev/null +++ b/ui/component/channelMentionSuggestions/index.js @@ -0,0 +1,36 @@ +import { connect } from 'react-redux'; +import { selectShowMatureContent } from 'redux/selectors/settings'; +import { selectSubscriptions } from 'redux/selectors/subscriptions'; +import { withRouter } from 'react-router'; +import { doResolveUris, makeSelectClaimForUri } from 'lbry-redux'; +import { makeSelectTopLevelCommentsForUri } from 'redux/selectors/comments'; +import ChannelMentionSuggestions from './view'; + +const select = (state, props) => { + const subscriptionUris = selectSubscriptions(state).map(({ uri }) => uri); + const topLevelComments = makeSelectTopLevelCommentsForUri(props.uri)(state); + + 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 new file mode 100644 index 000000000..9529ba717 --- /dev/null +++ b/ui/component/channelMentionSuggestions/view.jsx @@ -0,0 +1,285 @@ +// @flow +import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox'; +import { Form } from 'component/common/form'; +import { parseURI, regexInvalidURI } from 'lbry-redux'; +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, + isLivestream: boolean, + creatorUri: string, + commentorUris: Array, + subscriptionUris: Array, + unresolvedCommentors: Array, + unresolvedSubscriptions: Array, + canonicalCreator: string, + canonicalCommentors: Array, + canonicalSubscriptions: Array, + doResolveUris: (Array) => void, + customSelectAction?: (string, number) => void, +}; + +export default function ChannelMentionSuggestions(props: Props) { + const { + unresolvedCommentors, + unresolvedSubscriptions, + canonicalCreator, + isLivestream, + 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 { + const { channelName } = parseURI(uri); + return 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 commentors on Livestreams when actually mentioning/looking for it + React.useEffect(() => { + if (isLivestream && unresolvedCommentors && mentionTerm) doResolveUris(unresolvedCommentors); + }, [doResolveUris, isLivestream, mentionTerm, unresolvedCommentors]); + + // 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 new file mode 100644 index 000000000..d3a1d55b5 --- /dev/null +++ b/ui/component/channelMentionTopSuggestion/index.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { makeSelectIsUriResolving, doResolveUri } from 'lbry-redux'; +import { makeSelectWinningUriForQuery } from 'redux/selectors/search'; +import ChannelMentionTopSuggestion from './view'; + +const select = (state, props) => { + const uriFromQuery = `lbry://${props.query}`; + return { + uriFromQuery, + isResolvingUri: makeSelectIsUriResolving(uriFromQuery)(state), + 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 new file mode 100644 index 000000000..6ea79c233 --- /dev/null +++ b/ui/component/channelMentionTopSuggestion/view.jsx @@ -0,0 +1,49 @@ +// @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/claimLink/index.js b/ui/component/claimLink/index.js index 1da8ec075..39bd1e9f9 100644 --- a/ui/component/claimLink/index.js +++ b/ui/component/claimLink/index.js @@ -1,22 +1,32 @@ import { connect } from 'react-redux'; -import { doResolveUri, makeSelectTitleForUri, makeSelectClaimForUri, makeSelectIsUriResolving } from 'lbry-redux'; +import { doResolveUri, makeSelectClaimForUri, makeSelectIsUriResolving } from 'lbry-redux'; +import { doSetPlayingUri } from 'redux/actions/content'; import { selectBlackListedOutpoints } from 'lbryinc'; import { selectPlayingUri } from 'redux/selectors/content'; -import { doSetPlayingUri } from 'redux/actions/content'; import ClaimLink from './view'; const select = (state, props) => { + let uri = props.uri; + let claim; + + function getValidClaim(testUri) { + claim = makeSelectClaimForUri(testUri)(state); + if (claim === null) { + getValidClaim(testUri.substring(0, testUri.length - 1)); + } else { + uri = testUri; + } + } + getValidClaim(uri); + return { - uri: props.uri, - claim: makeSelectClaimForUri(props.uri)(state), - title: makeSelectTitleForUri(props.uri)(state), - isResolvingUri: makeSelectIsUriResolving(props.uri)(state), + uri, + claim, + fullUri: props.uri, + isResolvingUri: makeSelectIsUriResolving(uri)(state), blackListedOutpoints: selectBlackListedOutpoints(state), playingUri: selectPlayingUri(state), }; }; -export default connect(select, { - doResolveUri, - doSetPlayingUri, -})(ClaimLink); +export default connect(select, { doResolveUri, doSetPlayingUri })(ClaimLink); diff --git a/ui/component/claimLink/view.jsx b/ui/component/claimLink/view.jsx index f5e7ee69c..aae53873e 100644 --- a/ui/component/claimLink/view.jsx +++ b/ui/component/claimLink/view.jsx @@ -1,14 +1,15 @@ // @flow -import * as React from 'react'; -import classnames from 'classnames'; -import EmbedPlayButton from 'component/embedPlayButton'; -import Button from 'component/button'; -import UriIndicator from 'component/uriIndicator'; import { INLINE_PLAYER_WRAPPER_CLASS } from 'component/fileRenderFloating/view'; import { SIMPLE_SITE } from 'config'; +import * as React from 'react'; +import Button from 'component/button'; +import classnames from 'classnames'; +import EmbedPlayButton from 'component/embedPlayButton'; +import UriIndicator from 'component/uriIndicator'; type Props = { uri: string, + fullUri: string, claim: StreamClaim, children: React.Node, description: ?string, @@ -68,6 +69,7 @@ class ClaimLink extends React.Component { render() { const { uri, + fullUri, claim, children, isResolvingUri, @@ -92,7 +94,10 @@ class ClaimLink extends React.Component { const isChannel = valueType === 'channel'; return isChannel ? ( - + <> + + {fullUri.length > uri.length ? fullUri.substring(uri.length, fullUri.length) : ''} + ) : allowPreview ? (
({ - commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, claim: makeSelectClaimForUri(props.uri)(state), channels: selectMyChannelClaims(state), isFetchingChannels: selectFetchingMyChannels(state), @@ -38,8 +35,6 @@ const perform = (dispatch, ownProps) => ({ environment ) ), - openModal: (modal, props) => dispatch(doOpenModal(modal, props)), - setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)), sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)), doToast: (options) => dispatch(doToast(options)), doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)), diff --git a/ui/component/commentCreate/view.jsx b/ui/component/commentCreate/view.jsx index 91cd44875..40151c761 100644 --- a/ui/component/commentCreate/view.jsx +++ b/ui/component/commentCreate/view.jsx @@ -1,40 +1,39 @@ // @flow -import type { ElementRef } from 'react'; -import { SIMPLE_SITE } from 'config'; -import * as PAGES from 'constants/pages'; -import * as ICONS from 'constants/icons'; -import * as KEYCODES from 'constants/keycodes'; -import React from 'react'; -import classnames from 'classnames'; -import { FormField, Form } from 'component/common/form'; -import Icon from 'component/common/icon'; -import Button from 'component/button'; -import SelectChannel from 'component/selectChannel'; -import usePersistedState from 'effects/use-persisted-state'; import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field'; -import { useHistory } from 'react-router'; -import WalletTipAmountSelector from 'component/walletTipAmountSelector'; -import CreditAmount from 'component/common/credit-amount'; -import ChannelThumbnail from 'component/channelThumbnail'; -import I18nMessage from 'component/i18nMessage'; -import UriIndicator from 'component/uriIndicator'; -import Empty from 'component/common/empty'; +import { FormField, Form } from 'component/common/form'; import { getChannelIdFromClaim } from 'util/claim'; import { Lbryio } from 'lbryinc'; +import { SIMPLE_SITE } from 'config'; +import { useHistory } from 'react-router'; +import * as ICONS from 'constants/icons'; +import * as KEYCODES from 'constants/keycodes'; +import * as PAGES from 'constants/pages'; +import 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 Empty from 'component/common/empty'; +import I18nMessage from 'component/i18nMessage'; +import Icon from 'component/common/icon'; +import React from 'react'; +import SelectChannel from 'component/selectChannel'; +import type { ElementRef } from 'react'; +import UriIndicator from 'component/uriIndicator'; +import usePersistedState from 'effects/use-persisted-state'; +import WalletTipAmountSelector from 'component/walletTipAmountSelector'; import { getStripeEnvironment } from 'util/stripe'; let stripeEnvironment = getStripeEnvironment(); const TAB_FIAT = 'TabFiat'; const TAB_LBC = 'TabLBC'; +const MENTION_DEBOUNCE_MS = 100; type Props = { uri: string, claim: StreamClaim, - createComment: (string, string, string, ?string, ?string, ?string) => Promise, channels: ?Array, - onDoneReplying?: () => void, - onCancelReplying?: () => void, isNested: boolean, isFetchingChannels: boolean, parentId: string, @@ -44,25 +43,26 @@ type Props = { bottom: boolean, livestream?: boolean, embed?: boolean, - toast: (string) => void, claimIsMine: boolean, - sendTip: ({}, (any) => void, (any) => void) => void, - doToast: ({ message: string }) => void, supportDisabled: boolean, - doFetchCreatorSettings: (channelId: string) => Promise, settingsByChannelId: { [channelId: string]: PerChannelSettings }, + shouldFetchComment: boolean, + doToast: ({ message: string }) => void, + createComment: (string, string, string, ?string, ?string, ?string) => Promise, + onDoneReplying?: () => void, + onCancelReplying?: () => void, + toast: (string) => void, + sendTip: ({}, (any) => void, (any) => void) => void, + doFetchCreatorSettings: (channelId: string) => Promise, setQuickReply: (any) => void, fetchComment: (commentId: string) => Promise, - shouldFetchComment: boolean, }; export function CommentCreate(props: Props) { const { - createComment, + uri, claim, channels, - onDoneReplying, - onCancelReplying, isNested, isFetchingChannels, isReply, @@ -72,36 +72,64 @@ export function CommentCreate(props: Props) { livestream, embed, claimIsMine, - sendTip, - doToast, - doFetchCreatorSettings, settingsByChannelId, supportDisabled, + shouldFetchComment, + doToast, + createComment, + onDoneReplying, + onCancelReplying, + sendTip, + doFetchCreatorSettings, setQuickReply, fetchComment, - shouldFetchComment, } = props; + const formFieldRef: ElementRef = React.useRef(); + const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input; + const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart; const buttonRef: ElementRef = React.useRef(); const { push, location: { pathname }, } = useHistory(); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [commentFailure, setCommentFailure] = React.useState(false); const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined }); - const claimId = claim && claim.claim_id; const [isSupportComment, setIsSupportComment] = React.useState(); const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState(); const [tipAmount, setTipAmount] = React.useState(1); const [commentValue, setCommentValue] = React.useState(''); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); - const hasChannels = channels && channels.length; - const charCount = commentValue.length; const [activeTab, setActiveTab] = React.useState(''); const [tipError, setTipError] = React.useState(); const [deletedComment, setDeletedComment] = React.useState(false); - const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length; + 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 claimId = claim && claim.claim_id; + const signingChannel = (claim && claim.signing_channel) || claim; + const channelUri = signingChannel && signingChannel.permanent_url; + const hasChannels = channels && channels.length; + const charCount = commentValue ? commentValue.length : 0; + const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend; const channelId = getChannelIdFromClaim(claim); const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; @@ -109,15 +137,6 @@ export function CommentCreate(props: Props) { const minAmount = minTip || minSuper || 0; const minAmountMet = minAmount === 0 || tipAmount >= minAmount; - // Fetch top-level comments to identify if it has been deleted and can reply to it - React.useEffect(() => { - if (shouldFetchComment && fetchComment) { - fetchComment(parentId).then((result) => { - setDeletedComment(String(result).includes('Error')); - }); - } - }, [fetchComment, shouldFetchComment, parentId]); - const minAmountRef = React.useRef(minAmount); minAmountRef.current = minAmount; @@ -157,6 +176,20 @@ export function CommentCreate(props: Props) { setCommentValue(commentValue); } + function handleSelectMention(mentionValue, key) { + let newMentionValue = mentionValue.replace('lbry://', ''); + if (newMentionValue.includes('#')) newMentionValue = newMentionValue.replace('#', ':'); + + if (livestream && key !== KEYCODES.TAB) setPauseQuickSend(true); + setCommentValue( + commentValue.substring(0, selectedMentionIndex) + + `${newMentionValue}` + + (commentValue.length > mentionLengthIndex + 1 + ? commentValue.substring(mentionLengthIndex, commentValue.length) + : ' ') + ); + } + function altEnterListener(e: SyntheticKeyboardEvent<*>) { if ((livestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) { e.preventDefault(); @@ -365,10 +398,6 @@ export function CommentCreate(props: Props) { }); } - function toggleEditorMode() { - setAdvancedEditor(!advancedEditor); - } - // ************************************************************************** // Effects // ************************************************************************** @@ -378,7 +407,28 @@ export function CommentCreate(props: Props) { if (!channelSettings && channelId) { doFetchCreatorSettings(channelId); } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [channelId, channelSettings, doFetchCreatorSettings]); + + // Notifications: Fetch top-level comments to identify if it has been deleted and can reply to it + React.useEffect(() => { + if (shouldFetchComment && fetchComment) { + fetchComment(parentId).then((result) => { + setDeletedComment(String(result).includes('Error')); + }); + } + }, [fetchComment, shouldFetchComment, parentId]); + + // Debounce for disabling the submit button when mentioning a user with Enter + // so that the comment isn't sent at the same time + React.useEffect(() => { + const timer = setTimeout(() => { + if (pauseQuickSend) { + setPauseQuickSend(false); + } + }, MENTION_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [pauseQuickSend]); // ************************************************************************** // Render @@ -466,10 +516,22 @@ export function CommentCreate(props: Props) { 'comment__create--bottom': bottom, })} > + {!advancedEditor && ( + + )} {!livestream && ( @@ -481,7 +543,7 @@ export function CommentCreate(props: Props) { quickActionLabel={ !SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')) } - quickActionHandler={!SIMPLE_SITE && toggleEditorMode} + quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)} onFocus={onTextareaFocus} onBlur={onTextareaBlur} placeholder={__('Say something about this...')} diff --git a/ui/component/commentsList/view.jsx b/ui/component/commentsList/view.jsx index 181f9bad6..8235ebd75 100644 --- a/ui/component/commentsList/view.jsx +++ b/ui/component/commentsList/view.jsx @@ -278,6 +278,7 @@ function CommentList(props: Props) { return ( 0 ? totalComments === 1 diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 70e7aa195..7767f9e79 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -14,6 +14,7 @@ @import 'component/button'; @import 'component/card'; @import 'component/channel'; +@import 'component/channel-mention'; @import 'component/claim-list'; @import 'component/collection'; @import 'component/comments'; @@ -67,4 +68,3 @@ @import 'component/empty'; @import 'component/stripe-card'; @import 'component/wallet-tip-send'; - diff --git a/ui/scss/component/_channel-mention.scss b/ui/scss/component/_channel-mention.scss new file mode 100644 index 000000000..a10b11c4a --- /dev/null +++ b/ui/scss/component/_channel-mention.scss @@ -0,0 +1,125 @@ +.channel-mention { + display: flex; + align-items: center; + position: absolute; + bottom: calc(100% - 1.8rem); + z-index: 3; + font-size: var(--font-small); + padding-left: var(--spacing-s); + + > .icon { + top: 0; + left: var(--spacing-m); + height: 100%; + position: absolute; + z-index: 1; + stroke: var(--color-input-placeholder); + } + + @media (min-width: $breakpoint-small) { + padding: 0; + } +} + +.channel-mention__suggestions { + @extend .card; + display: flex; + flex-direction: column; + overflow-y: auto; + max-height: 30vh; + position: absolute; + text-overflow: ellipsis; + width: 22rem; + z-index: 3; + left: 0; + right: 0; + bottom: 0; + box-shadow: var(--card-box-shadow); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom: none; + + .channel-mention__label:first-of-type { + margin-top: var(--spacing-xs); + } +} + +.channel-mention__suggestions[flow-bottom] { + top: 4rem; + bottom: auto; + border-top-right-radius: 0; + border-top-left-radius: 0; + border-top: none; + border-bottom-right-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + border-bottom: auto; +} + +.channel-mention__input--none { + opacity: 0; + width: 0; + height: 0; +} + +.channel-mention__label { + @extend .wunderbar__label; +} + +.channel-mention__top-separator { + @extend .wunderbar__top-separator; +} + +.channel-mention__suggestion { + display: flex; + align-items: center; + padding: 0 var(--spacing-xxs); + margin-left: var(--spacing-xxs); + overflow: hidden; + text-overflow: ellipsis; + + .channel-thumbnail { + @include handleChannelGif(2.1rem); + position: absolute; + + @media (min-width: $breakpoint-small) { + @include handleChannelGif(2.1rem); + } + } +} + +.channel-mention__suggestion-label { + @extend .wunderbar__suggestion-label; + margin-left: var(--spacing-m); + display: block; + position: relative; +} + +.channel-mention__suggestion-name { + @extend .wunderbar__suggestion-name; + margin-left: calc(var(--spacing-l) - var(--spacing-xxs)); +} + +.channel-mention__suggestion-title { + @extend .wunderbar__suggestion-title; + margin-left: calc(var(--spacing-l) - var(--spacing-xxs)); +} + +.channel-mention__placeholder-suggestion { + @extend .wunderbar__placeholder-suggestion; + padding: 0 var(--spacing-xxs); + margin-left: var(--spacing-xxs); +} + +.channel-mention__placeholder-label { + @extend .wunderbar__placeholder-label; + margin-left: var(--spacing-m); +} + +.channel-mention__placeholder-thumbnail { + @extend .wunderbar__placeholder-thumbnail; + margin-left: var(--spacing-m); +} +.channel-mention__placeholder-info { + @extend .wunderbar__placeholder-info; + margin-left: var(--spacing-m); +} diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss index d63cbab57..1beab514c 100644 --- a/ui/scss/component/_comments.scss +++ b/ui/scss/component/_comments.scss @@ -34,6 +34,16 @@ $thumbnailWidthSmall: 1rem; .comment__create { font-size: var(--font-small); + position: relative; + + fieldset-section, + .form-field--SimpleMDE { + margin-top: 0; + } + + .form-field__two-column { + column-count: 2; + } } .comment__create--reply { @@ -80,6 +90,10 @@ $thumbnailWidthSmall: 1rem; } } +.content_comment { + position: relative; +} + .comment__thumbnail-wrapper { flex: 0; margin-top: var(--spacing-xxs); diff --git a/ui/scss/component/_livestream.scss b/ui/scss/component/_livestream.scss index 6e6c6104f..20e9559c7 100644 --- a/ui/scss/component/_livestream.scss +++ b/ui/scss/component/_livestream.scss @@ -359,6 +359,10 @@ $recent-msg-button__height: 2rem; p { word-break: break-word; } + + .channel-name { + font-size: var(--font-small); + } } } diff --git a/ui/util/remark-lbry.js b/ui/util/remark-lbry.js index 2a2fb1483..fc8bb17e6 100644 --- a/ui/util/remark-lbry.js +++ b/ui/util/remark-lbry.js @@ -3,6 +3,7 @@ import visit from 'unist-util-visit'; const protocol = 'lbry://'; const uriRegex = /(lbry:\/\/)[^\s"]*[^)]/g; +const punctuationMarks = [',', '.', '!', '?', ':', ';', '-', ']', ')', '}']; const mentionToken = '@'; // const mentionTokenCode = 64; // @ @@ -10,9 +11,24 @@ const mentionRegex = /@[^\s()"]*/gm; const invalidRegex = /[-_.+=?!@#$%^&*:;,{}<>\w/\\]/; +function handlePunctuation(value) { + const modifierIndex = + (value.indexOf(':') >= 0 && value.indexOf(':')) || (value.indexOf('#') >= 0 && value.indexOf('#')); + + let punctuationIndex; + punctuationMarks.some((p) => { + if (modifierIndex) { + punctuationIndex = value.indexOf(p, modifierIndex + 1) >= 0 && value.indexOf(p, modifierIndex + 1); + } + return punctuationIndex; + }); + + return punctuationIndex ? value.substring(0, punctuationIndex) : value; +} + // Find channel mention function locateMention(value, fromIndex) { - var index = value.indexOf(mentionToken, fromIndex); + const index = value.indexOf(mentionToken, fromIndex); // Skip invalid mention if (index > 0 && invalidRegex.test(value.charAt(index - 1))) { @@ -45,21 +61,22 @@ const createURI = (text, uri, embed = false) => ({ children: [{ type: 'text', value: text }], }); -const validateURI = (match, eat, self) => { +const validateURI = (match, eat) => { if (match) { try { const text = match[0]; - const uri = parseURI(text); + const newText = handlePunctuation(text); + const uri = parseURI(newText); const isValid = uri && uri.claimName; const isChannel = uri.isChannel && uri.path === uri.claimName; if (isValid) { // Create channel link if (isChannel) { - return eat(text)(createURI(uri.claimName, text, false)); + return eat(newText)(createURI(uri.claimName, newText, false)); } // Create claim link - return eat(text)(createURI(text, text, true)); + return eat(newText)(createURI(newText, newText, true)); } } catch (err) { // Silent errors: console.error(err) @@ -128,7 +145,7 @@ const visitor = (node, index, parent) => { }; // transform -const transform = tree => { +const transform = (tree) => { visit(tree, ['link'], visitor); }; diff --git a/web/scss/lbrytv.scss b/web/scss/lbrytv.scss index 4fbbe7f21..91b6201e5 100644 --- a/web/scss/lbrytv.scss +++ b/web/scss/lbrytv.scss @@ -15,6 +15,7 @@ @import '../../ui/scss/component/button'; @import '../../ui/scss/component/card'; @import '../../ui/scss/component/channel'; +@import '../../ui/scss/component/channel-mention'; @import '../../ui/scss/component/claim-list'; @import '../../ui/scss/component/collection'; @import '../../ui/scss/component/comments'; diff --git a/web/scss/odysee.scss b/web/scss/odysee.scss index fe9b4f2ed..1e2cf5ede 100644 --- a/web/scss/odysee.scss +++ b/web/scss/odysee.scss @@ -15,6 +15,7 @@ @import '../../ui/scss/component/button'; @import '../../ui/scss/component/card'; @import '../../ui/scss/component/channel'; +@import '../../ui/scss/component/channel-mention'; @import '../../ui/scss/component/claim-list'; @import '../../ui/scss/component/collection'; @import '../../ui/scss/component/comments';