Add Channel Mention selection ability #7151

Merged
saltrafael merged 9 commits from channel-mention into master 2021-09-30 23:30:32 +02:00
19 changed files with 744 additions and 81 deletions

View file

@ -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 "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 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 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 ### Changed
- Changing the supported language from Filipino to Tagalog _community pr!_ ([#6951](https://github.com/lbryio/lbry-desktop/pull/6951)) - Changing the supported language from Filipino to Tagalog _community pr!_ ([#6951](https://github.com/lbryio/lbry-desktop/pull/6951))

View file

@ -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);

View file

@ -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 : (
<ComboboxOption value={uri}>
{isResolvingUri ? (
<div className="channel-mention__suggestion">
<div className="media__thumb media__thumb--resolving" />
</div>
) : (
<div className="channel-mention__suggestion">
<ChannelThumbnail xsmall uri={uri} />
<span className="channel-mention__suggestion-label">
<div className="channel-mention__suggestion-title">{(claim.value && claim.value.title) || claim.name}</div>
<div className="channel-mention__suggestion-name">{claim.name}</div>
</span>
</div>
)}
</ComboboxOption>
);
}

View file

@ -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));

View file

@ -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<string>,
subscriptionUris: Array<string>,
unresolvedCommentors: Array<string>,
unresolvedSubscriptions: Array<string>,
canonicalCreator: string,
canonicalCommentors: Array<string>,
canonicalSubscriptions: Array<string>,
doResolveUris: (Array<string>) => 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<any> = React.useRef();
const comboboxListRef: ElementRef<any> = 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(() => {
jessopb commented 2021-09-22 21:42:54 +02:00 (Migrated from github.com)
Review
https://github.com/lbryio/lbry-desktop/blob/master/ui/effects/use-throttle.js would this work here?
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<string>,
canonical: Array<string>,
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 : (
<>
<div className="channel-mention__label">{label}</div>
{suggestions.map((uri) => (
<ChannelMentionSuggestion key={uri} uri={uri} />
))}
{hasSuggestionsBelow && <hr className="channel-mention__top-separator" />}
</>
);
};
return isRefFocused(inputRef) || isRefFocused(comboboxInputRef) ? (
<Form onSubmit={() => handleSelect(mentionTerm)}>
<Combobox className="channel-mention" onSelect={handleSelect}>
<ComboboxInput ref={comboboxInputRef} className="channel-mention__input--none" value={mentionTerm} />
{mentionTerm && isUriFromTermValid && (
<ComboboxPopover portal={false} className="channel-mention__suggestions">
<ComboboxList ref={comboboxListRef}>
{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 ? (
<Spinner type="small" />
) : (
results && (
<>
{!noTopSuggestion && (
<ChannelMentionTopSuggestion
query={debouncedTerm}
shownUris={allShownCanonical}
setMostSupported={(winningUri) => setMostSupported(winningUri)}
/>
)}
{suggestionsRow(__('From search'), results, canonicalResults, false)}
</>
)
))}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
</Form>
) : null;
}

View file

@ -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);

View file

@ -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<string>,
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 (
<div className="channel-mention__winning-claim">
<div className="channel-mention__label channel-mention__placeholder-label" />
<div className="channel-mention__suggestion channel-mention__placeholder-suggestion">
<div className="channel-mention__placeholder-thumbnail" />
<div className="channel-mention__placeholder-info" />
</div>
<hr className="channel-mention__top-separator" />
</div>
);
}
return !winningUri || shownUris.includes(winningUri) ? null : (
<>
<div className="channel-mention__label">
<LbcSymbol prefix={__('Most Supported')} />
</div>
<ChannelMentionSuggestion uri={winningUri} />
<hr className="channel-mention__top-separator" />
</>
);
}

View file

@ -1,22 +1,32 @@
import { connect } from 'react-redux'; 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 { selectBlackListedOutpoints } from 'lbryinc';
import { selectPlayingUri } from 'redux/selectors/content'; import { selectPlayingUri } from 'redux/selectors/content';
import { doSetPlayingUri } from 'redux/actions/content';
import ClaimLink from './view'; import ClaimLink from './view';
const select = (state, props) => { 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 { return {
uri: props.uri, uri,
claim: makeSelectClaimForUri(props.uri)(state), claim,
title: makeSelectTitleForUri(props.uri)(state), fullUri: props.uri,
isResolvingUri: makeSelectIsUriResolving(props.uri)(state), isResolvingUri: makeSelectIsUriResolving(uri)(state),
blackListedOutpoints: selectBlackListedOutpoints(state), blackListedOutpoints: selectBlackListedOutpoints(state),
playingUri: selectPlayingUri(state), playingUri: selectPlayingUri(state),
}; };
}; };
export default connect(select, { export default connect(select, { doResolveUri, doSetPlayingUri })(ClaimLink);
doResolveUri,
doSetPlayingUri,
})(ClaimLink);

View file

@ -1,14 +1,15 @@
// @flow // @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 { INLINE_PLAYER_WRAPPER_CLASS } from 'component/fileRenderFloating/view';
import { SIMPLE_SITE } from 'config'; 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 = { type Props = {
uri: string, uri: string,
fullUri: string,
claim: StreamClaim, claim: StreamClaim,
children: React.Node, children: React.Node,
description: ?string, description: ?string,
@ -68,6 +69,7 @@ class ClaimLink extends React.Component<Props> {
render() { render() {
const { const {
uri, uri,
fullUri,
claim, claim,
children, children,
isResolvingUri, isResolvingUri,
@ -92,7 +94,10 @@ class ClaimLink extends React.Component<Props> {
const isChannel = valueType === 'channel'; const isChannel = valueType === 'channel';
return isChannel ? ( return isChannel ? (
<UriIndicator uri={uri} link /> <>
<UriIndicator uri={uri} link />
<span>{fullUri.length > uri.length ? fullUri.substring(uri.length, fullUri.length) : ''}</span>
</>
) : allowPreview ? ( ) : allowPreview ? (
<div className={classnames('claim-link')}> <div className={classnames('claim-link')}>
<div <div

View file

@ -6,16 +6,13 @@ import {
selectFetchingMyChannels, selectFetchingMyChannels,
doSendTip, doSendTip,
} from 'lbry-redux'; } from 'lbry-redux';
import { doOpenModal, doSetActiveChannel } from 'redux/actions/app';
import { doCommentCreate, doFetchCreatorSettings, doCommentById } from 'redux/actions/comments'; import { doCommentCreate, doFetchCreatorSettings, doCommentById } from 'redux/actions/comments';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { selectSettingsByChannelId } from 'redux/selectors/comments'; import { selectSettingsByChannelId } from 'redux/selectors/comments';
import { CommentCreate } from './view'; import { CommentCreate } from './view';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
const select = (state, props) => ({ const select = (state, props) => ({
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
claim: makeSelectClaimForUri(props.uri)(state), claim: makeSelectClaimForUri(props.uri)(state),
channels: selectMyChannelClaims(state), channels: selectMyChannelClaims(state),
isFetchingChannels: selectFetchingMyChannels(state), isFetchingChannels: selectFetchingMyChannels(state),
@ -38,8 +35,6 @@ const perform = (dispatch, ownProps) => ({
environment environment
) )
), ),
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)), sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)),
doToast: (options) => dispatch(doToast(options)), doToast: (options) => dispatch(doToast(options)),
doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)), doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)),

View file

@ -1,40 +1,39 @@
// @flow // @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 { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
import { useHistory } from 'react-router'; import { FormField, Form } from 'component/common/form';
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 { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import { Lbryio } from 'lbryinc'; 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'; import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment(); let stripeEnvironment = getStripeEnvironment();
const TAB_FIAT = 'TabFiat'; const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC'; const TAB_LBC = 'TabLBC';
const MENTION_DEBOUNCE_MS = 100;
type Props = { type Props = {
uri: string, uri: string,
claim: StreamClaim, claim: StreamClaim,
createComment: (string, string, string, ?string, ?string, ?string) => Promise<any>,
channels: ?Array<ChannelClaim>, channels: ?Array<ChannelClaim>,
onDoneReplying?: () => void,
onCancelReplying?: () => void,
isNested: boolean, isNested: boolean,
isFetchingChannels: boolean, isFetchingChannels: boolean,
parentId: string, parentId: string,
@ -44,25 +43,26 @@ type Props = {
bottom: boolean, bottom: boolean,
livestream?: boolean, livestream?: boolean,
embed?: boolean, embed?: boolean,
toast: (string) => void,
claimIsMine: boolean, claimIsMine: boolean,
sendTip: ({}, (any) => void, (any) => void) => void,
doToast: ({ message: string }) => void,
supportDisabled: boolean, supportDisabled: boolean,
doFetchCreatorSettings: (channelId: string) => Promise<any>,
settingsByChannelId: { [channelId: string]: PerChannelSettings }, settingsByChannelId: { [channelId: string]: PerChannelSettings },
shouldFetchComment: boolean,
doToast: ({ message: string }) => void,
createComment: (string, string, string, ?string, ?string, ?string) => Promise<any>,
onDoneReplying?: () => void,
onCancelReplying?: () => void,
toast: (string) => void,
sendTip: ({}, (any) => void, (any) => void) => void,
doFetchCreatorSettings: (channelId: string) => Promise<any>,
setQuickReply: (any) => void, setQuickReply: (any) => void,
fetchComment: (commentId: string) => Promise<any>, fetchComment: (commentId: string) => Promise<any>,
shouldFetchComment: boolean,
}; };
export function CommentCreate(props: Props) { export function CommentCreate(props: Props) {
const { const {
createComment, uri,
claim, claim,
channels, channels,
onDoneReplying,
onCancelReplying,
isNested, isNested,
isFetchingChannels, isFetchingChannels,
isReply, isReply,
@ -72,36 +72,64 @@ export function CommentCreate(props: Props) {
livestream, livestream,
embed, embed,
claimIsMine, claimIsMine,
sendTip,
doToast,
doFetchCreatorSettings,
settingsByChannelId, settingsByChannelId,
supportDisabled, supportDisabled,
shouldFetchComment,
doToast,
createComment,
onDoneReplying,
onCancelReplying,
sendTip,
doFetchCreatorSettings,
setQuickReply, setQuickReply,
fetchComment, fetchComment,
shouldFetchComment,
} = props; } = props;
const formFieldRef: ElementRef<any> = React.useRef();
const formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input;
const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart;
const buttonRef: ElementRef<any> = React.useRef(); const buttonRef: ElementRef<any> = React.useRef();
const { const {
push, push,
location: { pathname }, location: { pathname },
} = useHistory(); } = useHistory();
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false);
const [commentFailure, setCommentFailure] = React.useState(false); const [commentFailure, setCommentFailure] = React.useState(false);
const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined }); const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined });
const claimId = claim && claim.claim_id;
const [isSupportComment, setIsSupportComment] = React.useState(); const [isSupportComment, setIsSupportComment] = React.useState();
const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState(); const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState();
const [tipAmount, setTipAmount] = React.useState(1); const [tipAmount, setTipAmount] = React.useState(1);
const [commentValue, setCommentValue] = React.useState(''); const [commentValue, setCommentValue] = React.useState('');
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const hasChannels = channels && channels.length;
const charCount = commentValue.length;
const [activeTab, setActiveTab] = React.useState(''); const [activeTab, setActiveTab] = React.useState('');
const [tipError, setTipError] = React.useState(); const [tipError, setTipError] = React.useState();
const [deletedComment, setDeletedComment] = React.useState(false); const [deletedComment, setDeletedComment] = React.useState(false);
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length; const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState(); 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 channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
@ -109,15 +137,6 @@ export function CommentCreate(props: Props) {
const minAmount = minTip || minSuper || 0; const minAmount = minTip || minSuper || 0;
const minAmountMet = minAmount === 0 || tipAmount >= minAmount; const minAmountMet = minAmount === 0 || tipAmount >= minAmount;
// 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); const minAmountRef = React.useRef(minAmount);
minAmountRef.current = minAmount; minAmountRef.current = minAmount;
@ -157,6 +176,20 @@ export function CommentCreate(props: Props) {
setCommentValue(commentValue); 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<*>) { function altEnterListener(e: SyntheticKeyboardEvent<*>) {
if ((livestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) { if ((livestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
e.preventDefault(); e.preventDefault();
@ -365,10 +398,6 @@ export function CommentCreate(props: Props) {
}); });
} }
function toggleEditorMode() {
setAdvancedEditor(!advancedEditor);
}
// ************************************************************************** // **************************************************************************
// Effects // Effects
// ************************************************************************** // **************************************************************************
@ -378,7 +407,28 @@ export function CommentCreate(props: Props) {
if (!channelSettings && channelId) { if (!channelSettings && channelId) {
doFetchCreatorSettings(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 // Render
@ -466,10 +516,22 @@ export function CommentCreate(props: Props) {
'comment__create--bottom': bottom, 'comment__create--bottom': bottom,
})} })}
> >
{!advancedEditor && (
<ChannelMentionSuggestions
uri={uri}
isLivestream={livestream}
inputRef={formFieldInputRef}
mentionTerm={channelMention}
creatorUri={channelUri}
customSelectAction={handleSelectMention}
/>
)}
<FormField <FormField
disabled={isFetchingChannels} disabled={isFetchingChannels}
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'} type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
name={isReply ? 'content_reply' : 'content_description'} name={isReply ? 'content_reply' : 'content_description'}
ref={formFieldRef}
className={isReply ? 'content_reply' : 'content_comment'}
label={ label={
<span className="comment-new__label-wrapper"> <span className="comment-new__label-wrapper">
{!livestream && ( {!livestream && (
@ -481,7 +543,7 @@ export function CommentCreate(props: Props) {
quickActionLabel={ quickActionLabel={
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')) !SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
} }
quickActionHandler={!SIMPLE_SITE && toggleEditorMode} quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
onFocus={onTextareaFocus} onFocus={onTextareaFocus}
onBlur={onTextareaBlur} onBlur={onTextareaBlur}
placeholder={__('Say something about this...')} placeholder={__('Say something about this...')}

View file

@ -278,6 +278,7 @@ function CommentList(props: Props) {
return ( return (
<Card <Card
className="card--enable-overflow"
title={ title={
totalComments > 0 totalComments > 0
? totalComments === 1 ? totalComments === 1

View file

@ -14,6 +14,7 @@
@import 'component/button'; @import 'component/button';
@import 'component/card'; @import 'component/card';
@import 'component/channel'; @import 'component/channel';
@import 'component/channel-mention';
@import 'component/claim-list'; @import 'component/claim-list';
@import 'component/collection'; @import 'component/collection';
@import 'component/comments'; @import 'component/comments';
@ -67,4 +68,3 @@
@import 'component/empty'; @import 'component/empty';
@import 'component/stripe-card'; @import 'component/stripe-card';
@import 'component/wallet-tip-send'; @import 'component/wallet-tip-send';

View file

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

View file

@ -34,6 +34,16 @@ $thumbnailWidthSmall: 1rem;
.comment__create { .comment__create {
font-size: var(--font-small); 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 { .comment__create--reply {
@ -80,6 +90,10 @@ $thumbnailWidthSmall: 1rem;
} }
} }
.content_comment {
position: relative;
}
.comment__thumbnail-wrapper { .comment__thumbnail-wrapper {
flex: 0; flex: 0;
margin-top: var(--spacing-xxs); margin-top: var(--spacing-xxs);

View file

@ -359,6 +359,10 @@ $recent-msg-button__height: 2rem;
p { p {
word-break: break-word; word-break: break-word;
} }
.channel-name {
font-size: var(--font-small);
}
} }
} }

View file

@ -3,6 +3,7 @@ import visit from 'unist-util-visit';
const protocol = 'lbry://'; const protocol = 'lbry://';
const uriRegex = /(lbry:\/\/)[^\s"]*[^)]/g; const uriRegex = /(lbry:\/\/)[^\s"]*[^)]/g;
const punctuationMarks = [',', '.', '!', '?', ':', ';', '-', ']', ')', '}'];
const mentionToken = '@'; const mentionToken = '@';
// const mentionTokenCode = 64; // @ // const mentionTokenCode = 64; // @
@ -10,9 +11,24 @@ const mentionRegex = /@[^\s()"]*/gm;
const invalidRegex = /[-_.+=?!@#$%^&*:;,{}<>\w/\\]/; 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 // Find channel mention
function locateMention(value, fromIndex) { function locateMention(value, fromIndex) {
var index = value.indexOf(mentionToken, fromIndex); const index = value.indexOf(mentionToken, fromIndex);
// Skip invalid mention // Skip invalid mention
if (index > 0 && invalidRegex.test(value.charAt(index - 1))) { if (index > 0 && invalidRegex.test(value.charAt(index - 1))) {
@ -45,21 +61,22 @@ const createURI = (text, uri, embed = false) => ({
children: [{ type: 'text', value: text }], children: [{ type: 'text', value: text }],
}); });
const validateURI = (match, eat, self) => { const validateURI = (match, eat) => {
if (match) { if (match) {
try { try {
const text = match[0]; const text = match[0];
const uri = parseURI(text); const newText = handlePunctuation(text);
const uri = parseURI(newText);
const isValid = uri && uri.claimName; const isValid = uri && uri.claimName;
const isChannel = uri.isChannel && uri.path === uri.claimName; const isChannel = uri.isChannel && uri.path === uri.claimName;
if (isValid) { if (isValid) {
// Create channel link // Create channel link
if (isChannel) { if (isChannel) {
return eat(text)(createURI(uri.claimName, text, false)); return eat(newText)(createURI(uri.claimName, newText, false));
} }
// Create claim link // Create claim link
return eat(text)(createURI(text, text, true)); return eat(newText)(createURI(newText, newText, true));
} }
} catch (err) { } catch (err) {
// Silent errors: console.error(err) // Silent errors: console.error(err)
@ -128,7 +145,7 @@ const visitor = (node, index, parent) => {
}; };
// transform // transform
const transform = tree => { const transform = (tree) => {
visit(tree, ['link'], visitor); visit(tree, ['link'], visitor);
}; };

View file

@ -15,6 +15,7 @@
@import '../../ui/scss/component/button'; @import '../../ui/scss/component/button';
@import '../../ui/scss/component/card'; @import '../../ui/scss/component/card';
@import '../../ui/scss/component/channel'; @import '../../ui/scss/component/channel';
@import '../../ui/scss/component/channel-mention';
@import '../../ui/scss/component/claim-list'; @import '../../ui/scss/component/claim-list';
@import '../../ui/scss/component/collection'; @import '../../ui/scss/component/collection';
@import '../../ui/scss/component/comments'; @import '../../ui/scss/component/comments';

View file

@ -15,6 +15,7 @@
@import '../../ui/scss/component/button'; @import '../../ui/scss/component/button';
@import '../../ui/scss/component/card'; @import '../../ui/scss/component/card';
@import '../../ui/scss/component/channel'; @import '../../ui/scss/component/channel';
@import '../../ui/scss/component/channel-mention';
@import '../../ui/scss/component/claim-list'; @import '../../ui/scss/component/claim-list';
@import '../../ui/scss/component/collection'; @import '../../ui/scss/component/collection';
@import '../../ui/scss/component/comments'; @import '../../ui/scss/component/comments';