Add Channel Mention selection ability #7151
19 changed files with 744 additions and 81 deletions
|
@ -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))
|
||||||
|
|
10
ui/component/channelMentionSuggestion/index.js
Normal file
10
ui/component/channelMentionSuggestion/index.js
Normal 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);
|
32
ui/component/channelMentionSuggestion/view.jsx
Normal file
32
ui/component/channelMentionSuggestion/view.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
36
ui/component/channelMentionSuggestions/index.js
Normal file
36
ui/component/channelMentionSuggestions/index.js
Normal 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));
|
285
ui/component/channelMentionSuggestions/view.jsx
Normal file
285
ui/component/channelMentionSuggestions/view.jsx
Normal 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(() => {
|
||||||
|
|||||||
|
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;
|
||||||
|
}
|
15
ui/component/channelMentionTopSuggestion/index.js
Normal file
15
ui/component/channelMentionTopSuggestion/index.js
Normal 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);
|
49
ui/component/channelMentionTopSuggestion/view.jsx
Normal file
49
ui/component/channelMentionTopSuggestion/view.jsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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...')}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
125
ui/scss/component/_channel-mention.scss
Normal file
125
ui/scss/component/_channel-mention.scss
Normal 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);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
Loading…
Reference in a new issue
https://github.com/lbryio/lbry-desktop/blob/master/ui/effects/use-throttle.js would this work here?