Refactor channelMention suggestions into new textareaSuggestions component
This commit is contained in:
parent
fcd72799b7
commit
aeb9536a4e
21 changed files with 653 additions and 745 deletions
|
@ -1,10 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims';
|
|
||||||
import ChannelMentionSuggestion from './view';
|
|
||||||
|
|
||||||
const select = (state, props) => ({
|
|
||||||
claim: selectClaimForUri(state, props.uri),
|
|
||||||
isResolvingUri: selectIsUriResolving(state, props.uri),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(select)(ChannelMentionSuggestion);
|
|
|
@ -1,32 +0,0 @@
|
||||||
// @flow
|
|
||||||
import { ComboboxOption } from '@reach/combobox';
|
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
claim: ?Claim,
|
|
||||||
uri?: string,
|
|
||||||
isResolvingUri: boolean,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ChannelMentionSuggestion(props: Props) {
|
|
||||||
const { claim, uri, isResolvingUri } = props;
|
|
||||||
|
|
||||||
return !claim ? null : (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
|
|
||||||
import { selectShowMatureContent } from 'redux/selectors/settings';
|
|
||||||
import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
|
|
||||||
import { withRouter } from 'react-router';
|
|
||||||
import { selectCanonicalUrlForUri } from 'redux/selectors/claims';
|
|
||||||
import { doResolveUris } from 'redux/actions/claims';
|
|
||||||
import { selectChannelMentionData } from 'redux/selectors/livestream';
|
|
||||||
import ChannelMentionSuggestions from './view';
|
|
||||||
|
|
||||||
const select = (state, props) => {
|
|
||||||
const maxComments = props.isLivestream ? MAX_LIVESTREAM_COMMENTS : -1;
|
|
||||||
const data = selectChannelMentionData(state, props.uri, maxComments);
|
|
||||||
|
|
||||||
return {
|
|
||||||
commentorUris: data.commentorUris,
|
|
||||||
subscriptionUris: selectSubscriptionUris(state),
|
|
||||||
unresolvedCommentors: data.unresolvedCommentors,
|
|
||||||
unresolvedSubscriptions: data.unresolvedSubscriptions,
|
|
||||||
canonicalCreator: selectCanonicalUrlForUri(state, props.creatorUri),
|
|
||||||
canonicalCommentors: data.canonicalCommentors,
|
|
||||||
canonicalSubscriptions: data.canonicalSubscriptions,
|
|
||||||
showMature: selectShowMatureContent(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withRouter(connect(select, { doResolveUris })(ChannelMentionSuggestions));
|
|
|
@ -1,286 +0,0 @@
|
||||||
// @flow
|
|
||||||
import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox';
|
|
||||||
import { Form } from 'component/common/form';
|
|
||||||
import { parseURI, regexInvalidURI } from 'util/lbryURI';
|
|
||||||
import { SEARCH_OPTIONS } from 'constants/search';
|
|
||||||
import * as KEYCODES from 'constants/keycodes';
|
|
||||||
import ChannelMentionSuggestion from 'component/channelMentionSuggestion';
|
|
||||||
import ChannelMentionTopSuggestion from 'component/channelMentionTopSuggestion';
|
|
||||||
import React from 'react';
|
|
||||||
import Spinner from 'component/spinner';
|
|
||||||
import type { ElementRef } from 'react';
|
|
||||||
import useLighthouse from 'effects/use-lighthouse';
|
|
||||||
|
|
||||||
const INPUT_DEBOUNCE_MS = 1000;
|
|
||||||
const LIGHTHOUSE_MIN_CHARACTERS = 3;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
inputRef: any,
|
|
||||||
mentionTerm: string,
|
|
||||||
noTopSuggestion?: boolean,
|
|
||||||
showMature: boolean,
|
|
||||||
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 {
|
|
||||||
// yuck a try catch in a filter?
|
|
||||||
const { channelName } = parseURI(uri);
|
|
||||||
return channelName && channelName.toLowerCase().includes(termToMatch);
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchSize = 5;
|
|
||||||
const additionalOptions = { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS };
|
|
||||||
const { results, loading } = useLighthouse(debouncedTerm, showMature, searchSize, additionalOptions, 0);
|
|
||||||
const stringifiedResults = JSON.stringify(results);
|
|
||||||
|
|
||||||
const hasMinLength = mentionTerm && mentionTerm.length >= LIGHTHOUSE_MIN_CHARACTERS;
|
|
||||||
const isTyping = debouncedTerm !== mentionTerm;
|
|
||||||
const showPlaceholder = isTyping || loading;
|
|
||||||
|
|
||||||
const isUriFromTermValid = !regexInvalidURI.test(mentionTerm.substring(1));
|
|
||||||
|
|
||||||
const handleSelect = React.useCallback(
|
|
||||||
(value, key) => {
|
|
||||||
if (customSelectAction) {
|
|
||||||
// Give them full results, as our resolved one might truncate the claimId.
|
|
||||||
customSelectAction(value || (results && results.find((r) => r.startsWith(value))) || '', Number(key));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[customSelectAction, results]
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (isTyping) setDebouncedTerm(!hasMinLength ? '' : mentionTerm);
|
|
||||||
}, INPUT_DEBOUNCE_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [hasMinLength, isTyping, mentionTerm]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!mainEl) return;
|
|
||||||
const header = document.querySelector('.header__navigation');
|
|
||||||
|
|
||||||
function handleReflow() {
|
|
||||||
const boxAtTopOfPage = header && mainEl.getBoundingClientRect().top <= header.offsetHeight;
|
|
||||||
const boxAtBottomOfPage = mainEl.getBoundingClientRect().bottom >= window.innerHeight;
|
|
||||||
|
|
||||||
if (boxAtTopOfPage) {
|
|
||||||
mainEl.setAttribute('flow-bottom', '');
|
|
||||||
}
|
|
||||||
if (mainEl.getAttribute('flow-bottom') !== null && boxAtBottomOfPage) {
|
|
||||||
mainEl.removeAttribute('flow-bottom');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleReflow();
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleReflow);
|
|
||||||
return () => window.removeEventListener('scroll', handleReflow);
|
|
||||||
}, [mainEl]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!inputRef || !comboboxInputRef || !mentionTerm) return;
|
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
|
||||||
const { keyCode } = event;
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
|
|
||||||
if (keyCode === KEYCODES.UP || keyCode === KEYCODES.DOWN) {
|
|
||||||
if (isRefFocused(comboboxInputRef)) {
|
|
||||||
const selectedId = activeElement && activeElement.getAttribute('aria-activedescendant');
|
|
||||||
const selectedItem = selectedId && document.querySelector(`li[id="${selectedId}"]`);
|
|
||||||
if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
||||||
} else {
|
|
||||||
// $FlowFixMe
|
|
||||||
comboboxInputRef.current.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ((isRefFocused(comboboxInputRef) || isRefFocused(inputRef)) && keyCode === KEYCODES.TAB) {
|
|
||||||
event.preventDefault();
|
|
||||||
const activeValue = activeElement && activeElement.getAttribute('value');
|
|
||||||
|
|
||||||
if (activeValue) {
|
|
||||||
handleSelect(activeValue, keyCode);
|
|
||||||
} else if (possibleMatches.length) {
|
|
||||||
// $FlowFixMe
|
|
||||||
const suggest = allShownCanonical.find((matchUri) => possibleMatches.find((uri) => uri.includes(matchUri)));
|
|
||||||
if (suggest) handleSelect(suggest, keyCode);
|
|
||||||
} else if (results) {
|
|
||||||
handleSelect(mentionTerm, keyCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isRefFocused(comboboxInputRef)) {
|
|
||||||
// $FlowFixMe
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [allShownCanonical, handleSelect, inputRef, mentionTerm, possibleMatches, results]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!stringifiedResults) return;
|
|
||||||
|
|
||||||
const arrayResults = JSON.parse(stringifiedResults);
|
|
||||||
if (arrayResults && arrayResults.length > 0) {
|
|
||||||
// $FlowFixMe
|
|
||||||
doResolveUris(arrayResults).then((response) => {
|
|
||||||
try {
|
|
||||||
// $FlowFixMe
|
|
||||||
const canonical_urls = Object.values(response).map(({ canonical_url }) => canonical_url);
|
|
||||||
setCanonicalResults(canonical_urls);
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [doResolveUris, stringifiedResults]);
|
|
||||||
|
|
||||||
// Only resolve 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;
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
// @flow
|
|
||||||
import ChannelMentionSuggestion from 'component/channelMentionSuggestion';
|
|
||||||
import LbcSymbol from 'component/common/lbc-symbol';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
uriFromQuery: string,
|
|
||||||
winningUri: string,
|
|
||||||
isResolvingUri: boolean,
|
|
||||||
shownUris: Array<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,5 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import 'scss/component/_comment-create.scss';
|
import 'scss/component/_comment-create.scss';
|
||||||
|
|
||||||
import { buildValidSticker } from 'util/comments';
|
import { buildValidSticker } from 'util/comments';
|
||||||
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 { FormField, Form } from 'component/common/form';
|
import { FormField, Form } from 'component/common/form';
|
||||||
|
@ -11,7 +13,6 @@ import * as ICONS from 'constants/icons';
|
||||||
import * as KEYCODES from 'constants/keycodes';
|
import * as KEYCODES from 'constants/keycodes';
|
||||||
import * as PAGES from 'constants/pages';
|
import * as PAGES from 'constants/pages';
|
||||||
import Button from 'component/button';
|
import Button from 'component/button';
|
||||||
import ChannelMentionSuggestions from 'component/channelMentionSuggestions';
|
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import CreditAmount from 'component/common/credit-amount';
|
import CreditAmount from 'component/common/credit-amount';
|
||||||
|
@ -34,7 +35,6 @@ const stripeEnvironment = getStripeEnvironment();
|
||||||
|
|
||||||
const TAB_FIAT = 'TabFiat';
|
const TAB_FIAT = 'TabFiat';
|
||||||
const TAB_LBC = 'TabLBC';
|
const TAB_LBC = 'TabLBC';
|
||||||
const MENTION_DEBOUNCE_MS = 100;
|
|
||||||
|
|
||||||
type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string };
|
type TipParams = { tipAmount: number, tipChannelName: string, channelClaimId: string };
|
||||||
type UserParams = { activeChannelName: ?string, activeChannelId: ?string };
|
type UserParams = { activeChannelName: ?string, activeChannelId: ?string };
|
||||||
|
@ -50,7 +50,7 @@ type Props = {
|
||||||
isFetchingChannels: boolean,
|
isFetchingChannels: boolean,
|
||||||
isNested: boolean,
|
isNested: boolean,
|
||||||
isReply: boolean,
|
isReply: boolean,
|
||||||
livestream?: boolean,
|
isLivestream?: boolean,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
settingsByChannelId: { [channelId: string]: PerChannelSettings },
|
settingsByChannelId: { [channelId: string]: PerChannelSettings },
|
||||||
shouldFetchComment: boolean,
|
shouldFetchComment: boolean,
|
||||||
|
@ -79,7 +79,7 @@ export function CommentCreate(props: Props) {
|
||||||
isFetchingChannels,
|
isFetchingChannels,
|
||||||
isNested,
|
isNested,
|
||||||
isReply,
|
isReply,
|
||||||
livestream,
|
isLivestream,
|
||||||
parentId,
|
parentId,
|
||||||
settingsByChannelId,
|
settingsByChannelId,
|
||||||
shouldFetchComment,
|
shouldFetchComment,
|
||||||
|
@ -97,8 +97,6 @@ export function CommentCreate(props: Props) {
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const formFieldRef: ElementRef<any> = React.useRef();
|
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 {
|
||||||
|
@ -121,33 +119,14 @@ export function CommentCreate(props: Props) {
|
||||||
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 [pauseQuickSend, setPauseQuickSend] = React.useState(false);
|
|
||||||
const [showEmotes, setShowEmotes] = React.useState(false);
|
const [showEmotes, setShowEmotes] = React.useState(false);
|
||||||
const [disableReviewButton, setDisableReviewButton] = React.useState();
|
const [disableReviewButton, setDisableReviewButton] = React.useState();
|
||||||
const [exchangeRate, setExchangeRate] = React.useState();
|
const [exchangeRate, setExchangeRate] = React.useState();
|
||||||
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(undefined);
|
const [canReceiveFiatTip, setCanReceiveFiatTip] = React.useState(undefined);
|
||||||
|
|
||||||
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 claimId = claim && claim.claim_id;
|
||||||
const channelUri = claim && (claim.signing_channel ? claim.signing_channel.permanent_url : claim.permanent_url);
|
|
||||||
const charCount = commentValue ? commentValue.length : 0;
|
const charCount = commentValue ? commentValue.length : 0;
|
||||||
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend;
|
const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length;
|
||||||
const channelId = getChannelIdFromClaim(claim);
|
const 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;
|
||||||
|
@ -176,33 +155,8 @@ export function CommentCreate(props: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommentChange(event) {
|
|
||||||
let commentValue;
|
|
||||||
if (isReply) {
|
|
||||||
commentValue = event.target.value;
|
|
||||||
} else {
|
|
||||||
commentValue = !SIMPLE_SITE && advancedEditor ? event : event.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ((isLivestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
buttonRef.current.click();
|
buttonRef.current.click();
|
||||||
}
|
}
|
||||||
|
@ -364,18 +318,6 @@ export function CommentCreate(props: Props) {
|
||||||
}
|
}
|
||||||
}, [fetchComment, shouldFetchComment, parentId]);
|
}, [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]);
|
|
||||||
|
|
||||||
// Stickers: Get LBC-USD exchange rate if hasn't yet and selected a paid sticker
|
// Stickers: Get LBC-USD exchange rate if hasn't yet and selected a paid sticker
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (stickerPrice && !exchangeRate) Lbryio.getExchangeRates().then(({ LBC_USD }) => setExchangeRate(LBC_USD));
|
if (stickerPrice && !exchangeRate) Lbryio.getExchangeRates().then(({ LBC_USD }) => setExchangeRate(LBC_USD));
|
||||||
|
@ -432,7 +374,7 @@ export function CommentCreate(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathPlusRedirect = `/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`;
|
const pathPlusRedirect = `/$/${PAGES.CHANNEL_NEW}?redirect=${pathname}`;
|
||||||
if (livestream) {
|
if (isLivestream) {
|
||||||
window.open(pathPlusRedirect);
|
window.open(pathPlusRedirect);
|
||||||
} else {
|
} else {
|
||||||
push(pathPlusRedirect);
|
push(pathPlusRedirect);
|
||||||
|
@ -501,45 +443,35 @@ export function CommentCreate(props: Props) {
|
||||||
closeSelector={() => setShowEmotes(false)}
|
closeSelector={() => setShowEmotes(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!advancedEditor && (
|
|
||||||
<ChannelMentionSuggestions
|
|
||||||
uri={uri}
|
|
||||||
isLivestream={livestream}
|
|
||||||
inputRef={formFieldInputRef}
|
|
||||||
mentionTerm={channelMention}
|
|
||||||
creatorUri={channelUri}
|
|
||||||
customSelectAction={handleSelectMention}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
autoFocus={isReply}
|
||||||
|
charCount={charCount}
|
||||||
|
className={isReply ? 'create__reply' : 'create___comment'}
|
||||||
disabled={isFetchingChannels}
|
disabled={isFetchingChannels}
|
||||||
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
|
isLivestream={isLivestream}
|
||||||
name={isReply ? 'content_reply' : 'content_description'}
|
|
||||||
ref={formFieldRef}
|
|
||||||
className={isReply ? 'content_reply' : 'content_comment'}
|
|
||||||
label={
|
label={
|
||||||
<span className="commentCreate__labelWrapper">
|
<div className="commentCreate__labelWrapper">
|
||||||
{!livestream && (
|
<span className="commentCreate__label">
|
||||||
<div className="commentCreate__label">{(isReply ? __('Replying as') : __('Comment as')) + ' '}</div>
|
{(isReply ? __('Replying as') : isLivestream ? __('Chat as') : __('Comment as')) + ' '}
|
||||||
)}
|
</span>
|
||||||
<SelectChannel tiny />
|
<SelectChannel tiny />
|
||||||
</span>
|
</div>
|
||||||
}
|
}
|
||||||
|
name={isReply ? 'create__reply' : 'create___comment'}
|
||||||
|
onBlur={() => window.removeEventListener('keydown', altEnterListener)}
|
||||||
|
onChange={(e) => setCommentValue(SIMPLE_SITE || !advancedEditor || isReply ? e.target.value : e)}
|
||||||
|
onFocus={() => window.addEventListener('keydown', altEnterListener)}
|
||||||
|
openEmoteMenu={() => setShowEmotes(!showEmotes)}
|
||||||
|
placeholder={__('Say something about this...')}
|
||||||
|
quickActionHandler={!SIMPLE_SITE ? () => setAdvancedEditor(!advancedEditor) : undefined}
|
||||||
quickActionLabel={
|
quickActionLabel={
|
||||||
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
|
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
|
||||||
}
|
}
|
||||||
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
|
ref={formFieldRef}
|
||||||
openEmoteMenu={() => setShowEmotes(!showEmotes)}
|
textAreaMaxLength={isLivestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
|
||||||
onFocus={() => window.addEventListener('keydown', altEnterListener)}
|
type={!SIMPLE_SITE && advancedEditor && !isReply ? 'markdown' : 'textarea'}
|
||||||
onBlur={() => window.removeEventListener('keydown', altEnterListener)}
|
uri={uri}
|
||||||
placeholder={__('Say something about this...')}
|
|
||||||
value={commentValue}
|
value={commentValue}
|
||||||
charCount={charCount}
|
|
||||||
onChange={handleCommentChange}
|
|
||||||
autoFocus={isReply}
|
|
||||||
textAreaMaxLength={livestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -8,38 +8,43 @@ import MarkdownPreview from 'component/common/markdown-preview';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOMServer from 'react-dom/server';
|
import ReactDOMServer from 'react-dom/server';
|
||||||
import SimpleMDE from 'react-simplemde-editor';
|
import SimpleMDE from 'react-simplemde-editor';
|
||||||
|
import TextareaWithSuggestions from 'component/textareaWithSuggestions';
|
||||||
import type { ElementRef, Node } from 'react';
|
import type { ElementRef, Node } from 'react';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name: string,
|
|
||||||
label?: string | Node,
|
|
||||||
prefix?: string,
|
|
||||||
postfix?: string,
|
|
||||||
error?: string | boolean,
|
|
||||||
helper?: string | React$Node,
|
|
||||||
type?: string,
|
|
||||||
defaultValue?: string | number,
|
|
||||||
placeholder?: string | number,
|
|
||||||
children?: React$Node,
|
|
||||||
stretch?: boolean,
|
|
||||||
affixClass?: string, // class applied to prefix/postfix label
|
affixClass?: string, // class applied to prefix/postfix label
|
||||||
autoFocus?: boolean,
|
autoFocus?: boolean,
|
||||||
labelOnLeft: boolean,
|
|
||||||
inputButton?: React$Node,
|
|
||||||
blockWrap: boolean,
|
blockWrap: boolean,
|
||||||
charCount?: number,
|
charCount?: number,
|
||||||
textAreaMaxLength?: number,
|
children?: React$Node,
|
||||||
range?: number,
|
defaultValue?: string | number,
|
||||||
min?: number,
|
|
||||||
max?: number,
|
|
||||||
quickActionLabel?: string,
|
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
value?: string | number,
|
error?: string | boolean,
|
||||||
|
helper?: string | React$Node,
|
||||||
|
hideSuggestions?: boolean,
|
||||||
|
inputButton?: React$Node,
|
||||||
|
isLivestream?: boolean,
|
||||||
|
label?: string | Node,
|
||||||
|
labelOnLeft: boolean,
|
||||||
|
max?: number,
|
||||||
|
min?: number,
|
||||||
|
name: string,
|
||||||
noEmojis?: boolean,
|
noEmojis?: boolean,
|
||||||
render?: () => React$Node,
|
placeholder?: string | number,
|
||||||
|
postfix?: string,
|
||||||
|
prefix?: string,
|
||||||
|
quickActionLabel?: string,
|
||||||
|
range?: number,
|
||||||
|
readOnly?: boolean,
|
||||||
|
stretch?: boolean,
|
||||||
|
textAreaMaxLength?: number,
|
||||||
|
type?: string,
|
||||||
|
uri?: string,
|
||||||
|
value?: string | number,
|
||||||
onChange?: (any) => any,
|
onChange?: (any) => any,
|
||||||
quickActionHandler?: (any) => any,
|
|
||||||
openEmoteMenu?: () => void,
|
openEmoteMenu?: () => void,
|
||||||
|
quickActionHandler?: (any) => any,
|
||||||
|
render?: () => React$Node,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FormField extends React.PureComponent<Props> {
|
export class FormField extends React.PureComponent<Props> {
|
||||||
|
@ -61,27 +66,30 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
label,
|
|
||||||
prefix,
|
|
||||||
postfix,
|
|
||||||
error,
|
|
||||||
helper,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
children,
|
|
||||||
stretch,
|
|
||||||
affixClass,
|
affixClass,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
inputButton,
|
|
||||||
labelOnLeft,
|
|
||||||
blockWrap,
|
blockWrap,
|
||||||
charCount,
|
charCount,
|
||||||
textAreaMaxLength,
|
children,
|
||||||
quickActionLabel,
|
error,
|
||||||
|
helper,
|
||||||
|
hideSuggestions,
|
||||||
|
inputButton,
|
||||||
|
isLivestream,
|
||||||
|
label,
|
||||||
|
labelOnLeft,
|
||||||
|
name,
|
||||||
noEmojis,
|
noEmojis,
|
||||||
render,
|
postfix,
|
||||||
quickActionHandler,
|
prefix,
|
||||||
|
quickActionLabel,
|
||||||
|
stretch,
|
||||||
|
textAreaMaxLength,
|
||||||
|
type,
|
||||||
|
uri,
|
||||||
openEmoteMenu,
|
openEmoteMenu,
|
||||||
|
quickActionHandler,
|
||||||
|
render,
|
||||||
...inputProps
|
...inputProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
@ -231,13 +239,28 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
{quickAction}
|
{quickAction}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<textarea
|
|
||||||
type={type}
|
{hideSuggestions ? (
|
||||||
id={name}
|
<textarea
|
||||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
type={type}
|
||||||
ref={this.input}
|
id={name}
|
||||||
{...inputProps}
|
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||||
/>
|
ref={this.input}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextareaWithSuggestions
|
||||||
|
type={type}
|
||||||
|
id={name}
|
||||||
|
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||||
|
inputRef={this.input}
|
||||||
|
hideSuggestions={hideSuggestions}
|
||||||
|
uri={uri}
|
||||||
|
isLivestream={isLivestream}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-field__textarea-info">
|
<div className="form-field__textarea-info">
|
||||||
{!noEmojis && openEmoteMenu && (
|
{!noEmojis && openEmoteMenu && (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -390,7 +390,7 @@ export default function LivestreamComments(props: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="livestream__comment-create">
|
<div className="livestream__comment-create">
|
||||||
<CommentCreate livestream bottom embed={embed} uri={uri} onDoneReplying={restoreScrollPos} />
|
<CommentCreate isLivestream bottom embed={embed} uri={uri} onDoneReplying={restoreScrollPos} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
10
ui/component/textareaSuggestionsItem/index.js
Normal file
10
ui/component/textareaSuggestionsItem/index.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims';
|
||||||
|
import TextareaSuggestionsItem from './view';
|
||||||
|
|
||||||
|
const select = (state, props) => ({
|
||||||
|
claim: props.uri && selectClaimForUri(state, props.uri),
|
||||||
|
isResolvingUri: props.uri && selectIsUriResolving(state, props.uri),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(select)(TextareaSuggestionsItem);
|
43
ui/component/textareaSuggestionsItem/view.jsx
Normal file
43
ui/component/textareaSuggestionsItem/view.jsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// @flow
|
||||||
|
import { ComboboxOption, ComboboxOptionText } from '@reach/combobox';
|
||||||
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
claim?: Claim,
|
||||||
|
isResolvingUri: boolean,
|
||||||
|
uri?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TextareaSuggestionsItem(props: Props) {
|
||||||
|
const { claim, isResolvingUri, uri } = props;
|
||||||
|
|
||||||
|
if (!claim) return null;
|
||||||
|
|
||||||
|
if (isResolvingUri) {
|
||||||
|
return (
|
||||||
|
<div className="textareaSuggestion">
|
||||||
|
<div className="media__thumb media__thumb--resolving" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalMention = claim.canonical_url.replace('lbry://', '').replace('#', ':');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxOption value={canonicalMention}>
|
||||||
|
<div className="textareaSuggestion">
|
||||||
|
<ChannelThumbnail xsmall uri={uri} />
|
||||||
|
|
||||||
|
<div className="textareaSuggestion__label">
|
||||||
|
<span className="textareaSuggestion__title">
|
||||||
|
{(claim.value && claim.value.title) || <ComboboxOptionText />}
|
||||||
|
</span>
|
||||||
|
<span className="textareaSuggestion__value">
|
||||||
|
<ComboboxOptionText />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComboboxOption>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,15 +2,16 @@ import { connect } from 'react-redux';
|
||||||
import { selectIsUriResolving } from 'redux/selectors/claims';
|
import { selectIsUriResolving } from 'redux/selectors/claims';
|
||||||
import { doResolveUri } from 'redux/actions/claims';
|
import { doResolveUri } from 'redux/actions/claims';
|
||||||
import { makeSelectWinningUriForQuery } from 'redux/selectors/search';
|
import { makeSelectWinningUriForQuery } from 'redux/selectors/search';
|
||||||
import ChannelMentionTopSuggestion from './view';
|
import TextareaTopSuggestion from './view';
|
||||||
|
|
||||||
const select = (state, props) => {
|
const select = (state, props) => {
|
||||||
const uriFromQuery = `lbry://${props.query}`;
|
const uriFromQuery = `lbry://${props.query}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uriFromQuery,
|
|
||||||
isResolvingUri: selectIsUriResolving(state, uriFromQuery),
|
isResolvingUri: selectIsUriResolving(state, uriFromQuery),
|
||||||
|
uriFromQuery,
|
||||||
winningUri: makeSelectWinningUriForQuery(props.query)(state),
|
winningUri: makeSelectWinningUriForQuery(props.query)(state),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(select, { doResolveUri })(ChannelMentionTopSuggestion);
|
export default connect(select, { doResolveUri })(TextareaTopSuggestion);
|
44
ui/component/textareaTopSuggestion/view.jsx
Normal file
44
ui/component/textareaTopSuggestion/view.jsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// @flow
|
||||||
|
import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
|
||||||
|
import LbcSymbol from 'component/common/lbc-symbol';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filteredTop: string,
|
||||||
|
isResolvingUri: boolean,
|
||||||
|
uriFromQuery: string,
|
||||||
|
winningUri: string,
|
||||||
|
doResolveUri: (string) => void,
|
||||||
|
setTopSuggestion: (string) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TextareaTopSuggestion(props: Props) {
|
||||||
|
const { filteredTop, isResolvingUri, uriFromQuery, winningUri, doResolveUri, setTopSuggestion } = props;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (uriFromQuery) doResolveUri(uriFromQuery);
|
||||||
|
}, [doResolveUri, uriFromQuery]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (winningUri) setTopSuggestion(winningUri);
|
||||||
|
}, [setTopSuggestion, winningUri]);
|
||||||
|
|
||||||
|
if (isResolvingUri) {
|
||||||
|
return (
|
||||||
|
<div className="textareaSuggestion">
|
||||||
|
<div className="media__thumb media__thumb--resolving" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredTop && filteredTop.length > 0 ? (
|
||||||
|
<div className="textareaSuggestions__row">
|
||||||
|
<div className="textareaSuggestions__label">
|
||||||
|
<LbcSymbol prefix={__('Most Supported')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextareaSuggestionsItem uri={filteredTop} />
|
||||||
|
<hr className="textareaSuggestions__topSeparator" />
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
30
ui/component/textareaWithSuggestions/index.js
Normal file
30
ui/component/textareaWithSuggestions/index.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { doResolveUris } from 'redux/actions/claims';
|
||||||
|
import { getChannelFromClaim } from 'util/claim';
|
||||||
|
import { makeSelectClaimForUri } from 'redux/selectors/claims';
|
||||||
|
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
|
||||||
|
import { selectChannelMentionData } from 'redux/selectors/comments';
|
||||||
|
import { selectShowMatureContent } from 'redux/selectors/settings';
|
||||||
|
import { withRouter } from 'react-router';
|
||||||
|
import TextareaWithSuggestions from './view';
|
||||||
|
|
||||||
|
const select = (state, props) => {
|
||||||
|
const claim = makeSelectClaimForUri(props.uri)(state);
|
||||||
|
const maxComments = props.isLivestream ? MAX_LIVESTREAM_COMMENTS : -1;
|
||||||
|
const data = selectChannelMentionData(state, props.uri, maxComments);
|
||||||
|
const { canonicalCommentors, canonicalSubscriptions, commentorUris } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalCommentors,
|
||||||
|
canonicalCreatorUri: getChannelFromClaim(claim) && getChannelFromClaim(claim).canonical_url,
|
||||||
|
canonicalSubscriptions,
|
||||||
|
commentorUris,
|
||||||
|
showMature: selectShowMatureContent(state),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const perform = (dispatch) => ({
|
||||||
|
doResolveUris: (uris) => dispatch(doResolveUris(uris, true)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withRouter(connect(select, perform)(TextareaWithSuggestions));
|
298
ui/component/textareaWithSuggestions/view.jsx
Normal file
298
ui/component/textareaWithSuggestions/view.jsx
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
// @flow
|
||||||
|
import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox';
|
||||||
|
import { matchSorter } from 'match-sorter';
|
||||||
|
import { SEARCH_OPTIONS } from 'constants/search';
|
||||||
|
import * as KEYCODES from 'constants/keycodes';
|
||||||
|
import { regexInvalidURI } from 'util/lbryURI';
|
||||||
|
import React from 'react';
|
||||||
|
import Spinner from 'component/spinner';
|
||||||
|
import TextareaSuggestionsItem from 'component/textareaSuggestionsItem';
|
||||||
|
import TextareaTopSuggestion from 'component/textareaTopSuggestion';
|
||||||
|
import type { ElementRef } from 'react';
|
||||||
|
import useLighthouse from 'effects/use-lighthouse';
|
||||||
|
import useThrottle from 'effects/use-throttle';
|
||||||
|
|
||||||
|
const mentionRegex = /@[^\s"=?!@$%^&*;,{}<>/\\]*/gm;
|
||||||
|
|
||||||
|
const INPUT_DEBOUNCE_MS = 1000;
|
||||||
|
const LIGHTHOUSE_MIN_CHARACTERS = 4;
|
||||||
|
const SEARCH_SIZE = 10;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
canonicalCommentors: Array<string>,
|
||||||
|
canonicalCreatorUri: string,
|
||||||
|
canonicalSubscriptions: Array<string>,
|
||||||
|
className?: string,
|
||||||
|
commentorUris: Array<string>,
|
||||||
|
doResolveUris: (Array<string>) => void,
|
||||||
|
hideSuggestions?: boolean,
|
||||||
|
inputRef: any,
|
||||||
|
isLivestream?: boolean,
|
||||||
|
maxLength?: number,
|
||||||
|
name: string,
|
||||||
|
noTopSuggestion?: boolean,
|
||||||
|
placeholder?: string,
|
||||||
|
showMature: boolean,
|
||||||
|
type?: string,
|
||||||
|
value: any,
|
||||||
|
onChange: (any) => any,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TextareaWithSuggestions(props: Props) {
|
||||||
|
const {
|
||||||
|
canonicalCommentors,
|
||||||
|
canonicalCreatorUri,
|
||||||
|
canonicalSubscriptions,
|
||||||
|
className,
|
||||||
|
commentorUris,
|
||||||
|
doResolveUris,
|
||||||
|
hideSuggestions,
|
||||||
|
inputRef,
|
||||||
|
isLivestream,
|
||||||
|
maxLength,
|
||||||
|
name,
|
||||||
|
noTopSuggestion,
|
||||||
|
placeholder,
|
||||||
|
showMature,
|
||||||
|
type,
|
||||||
|
value: commentValue,
|
||||||
|
onChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inputProps = { className, placeholder };
|
||||||
|
|
||||||
|
const comboboxListRef: ElementRef<any> = React.useRef();
|
||||||
|
|
||||||
|
const [suggestionValue, setSuggestionValue] = React.useState(undefined);
|
||||||
|
const [debouncedTerm, setDebouncedTerm] = React.useState('');
|
||||||
|
const [topSuggestion, setTopSuggestion] = React.useState('');
|
||||||
|
const [canonicalSearchUris, setCanonicalSearchUris] = React.useState([]);
|
||||||
|
|
||||||
|
const suggestionTerm = suggestionValue && suggestionValue.term;
|
||||||
|
const isUriFromTermValid = suggestionTerm && !regexInvalidURI.test(suggestionTerm.substring(1));
|
||||||
|
|
||||||
|
const additionalOptions = { isBackgroundSearch: false, [SEARCH_OPTIONS.CLAIM_TYPE]: SEARCH_OPTIONS.INCLUDE_CHANNELS };
|
||||||
|
const { results, loading } = useLighthouse(debouncedTerm, showMature, SEARCH_SIZE, additionalOptions, 0);
|
||||||
|
const stringifiedResults = JSON.stringify(results);
|
||||||
|
|
||||||
|
const shouldFilter = (uri, previousLists) =>
|
||||||
|
uri !== canonicalCreatorUri && (!previousLists || !previousLists.includes(uri));
|
||||||
|
|
||||||
|
const filteredCommentors = canonicalCommentors.filter((uri) => shouldFilter(uri));
|
||||||
|
const filteredSubs = canonicalSubscriptions.filter((uri) => shouldFilter(uri, filteredCommentors));
|
||||||
|
const filteredTop = shouldFilter(topSuggestion, [...filteredCommentors, ...filteredSubs]) && topSuggestion;
|
||||||
|
const filteredSearch =
|
||||||
|
canonicalSearchUris &&
|
||||||
|
canonicalSearchUris.filter((uri) => shouldFilter(uri, [...filteredCommentors, ...filteredSubs, filteredTop || '']));
|
||||||
|
|
||||||
|
const creatorUriMatch = useSuggestionMatch(suggestionTerm || '', [canonicalCreatorUri]);
|
||||||
|
const subscriptionsMatch = useSuggestionMatch(suggestionTerm || '', filteredSubs);
|
||||||
|
const commentorsMatch = useSuggestionMatch(suggestionTerm || '', filteredCommentors);
|
||||||
|
|
||||||
|
const hasMinSearchLength = suggestionTerm && suggestionTerm.length >= LIGHTHOUSE_MIN_CHARACTERS;
|
||||||
|
const isTyping = suggestionValue && debouncedTerm !== suggestionValue.term;
|
||||||
|
const showPlaceholder = hasMinSearchLength && (isTyping || loading);
|
||||||
|
|
||||||
|
/** --------- **/
|
||||||
|
/** Functions **/
|
||||||
|
/** --------- **/
|
||||||
|
|
||||||
|
function handleChange(e: SyntheticInputEvent<*>) {
|
||||||
|
onChange(e);
|
||||||
|
|
||||||
|
if (hideSuggestions) return;
|
||||||
|
|
||||||
|
const { value } = e.target;
|
||||||
|
|
||||||
|
const cursorIndex = inputRef && inputRef.current && inputRef.current.selectionStart;
|
||||||
|
const mentionMatches = value.match(mentionRegex);
|
||||||
|
|
||||||
|
const matchIndexes = [];
|
||||||
|
let mentionIndex;
|
||||||
|
let mentionLastIndex;
|
||||||
|
|
||||||
|
const mentionValue =
|
||||||
|
mentionMatches &&
|
||||||
|
mentionMatches.find((match, index) => {
|
||||||
|
const previousIndex = matchIndexes[index - 1] + 1 || 0;
|
||||||
|
mentionIndex = value.substring(previousIndex).search(mentionRegex) + previousIndex;
|
||||||
|
matchIndexes.push(mentionIndex);
|
||||||
|
|
||||||
|
// the current mention term will be the one on the text cursor's range,
|
||||||
|
// in case of there being more in the same comment message
|
||||||
|
if (matchIndexes) {
|
||||||
|
mentionLastIndex = mentionIndex + match.length;
|
||||||
|
|
||||||
|
if (cursorIndex >= mentionIndex && cursorIndex <= mentionLastIndex) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mentionValue) {
|
||||||
|
// $FlowFixMe
|
||||||
|
setSuggestionValue({ term: mentionValue, index: mentionIndex, lastIndex: mentionLastIndex });
|
||||||
|
} else if (suggestionValue) {
|
||||||
|
setSuggestionValue(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = React.useCallback(
|
||||||
|
(selectedValue: string) => {
|
||||||
|
if (!suggestionValue) return;
|
||||||
|
|
||||||
|
const newValue =
|
||||||
|
commentValue.substring(0, suggestionValue.index) + // 1) From start of comment value until term start
|
||||||
|
`${selectedValue}` + // 2) Add the selected value
|
||||||
|
(commentValue.length > suggestionValue.lastIndex // 3) If there is more content until the the end of the comment value:
|
||||||
|
? commentValue.substring(suggestionValue.index + 1, commentValue.length) // 3.a) from term end, add the rest of comment value
|
||||||
|
: ' '); // 3.b) or else, add a space for new input after
|
||||||
|
|
||||||
|
onChange({ target: { value: newValue } });
|
||||||
|
inputRef.current.focus();
|
||||||
|
},
|
||||||
|
[commentValue, inputRef, onChange, suggestionValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** ------- **/
|
||||||
|
/** Effects **/
|
||||||
|
/** ------- **/
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (isTyping && suggestionValue) setDebouncedTerm(!hasMinSearchLength ? '' : suggestionValue.term);
|
||||||
|
}, INPUT_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [hasMinSearchLength, isTyping, suggestionValue]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!stringifiedResults) return;
|
||||||
|
|
||||||
|
const arrayResults = JSON.parse(stringifiedResults);
|
||||||
|
if (doResolveUris && arrayResults && arrayResults.length > 0) {
|
||||||
|
// $FlowFixMe
|
||||||
|
doResolveUris(arrayResults)
|
||||||
|
.then((response) => {
|
||||||
|
try {
|
||||||
|
// $FlowFixMe
|
||||||
|
const canonical_urls = Object.values(response).map(({ canonical_url }) => canonical_url);
|
||||||
|
setCanonicalSearchUris(canonical_urls);
|
||||||
|
} catch (e) {}
|
||||||
|
})
|
||||||
|
.catch((e) => {});
|
||||||
|
}
|
||||||
|
}, [doResolveUris, stringifiedResults]);
|
||||||
|
|
||||||
|
// Only resolve commentors on Livestreams when actually mentioning/looking for it
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLivestream && commentorUris && suggestionValue) doResolveUris(commentorUris);
|
||||||
|
}, [commentorUris, doResolveUris, isLivestream, suggestionValue]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!inputRef || !suggestionValue) return;
|
||||||
|
|
||||||
|
function handleKeyDown(e: SyntheticKeyboardEvent<*>) {
|
||||||
|
const { keyCode } = e;
|
||||||
|
|
||||||
|
const activeSelection = document.querySelector('[data-reach-combobox-option][data-highlighted]');
|
||||||
|
const firstValue = document.querySelectorAll('[data-reach-combobox-option] .textareaSuggestion__value')[0];
|
||||||
|
|
||||||
|
if (keyCode === KEYCODES.UP || keyCode === KEYCODES.DOWN) {
|
||||||
|
const selectedId = activeSelection && activeSelection.getAttribute('id');
|
||||||
|
const selectedItem = selectedId && document.querySelector(`li[id="${selectedId}"]`);
|
||||||
|
|
||||||
|
if (selectedItem) selectedItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
} else if (keyCode === KEYCODES.TAB) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const activeValue = document.querySelector(
|
||||||
|
'[data-reach-combobox-option][data-highlighted] .textareaSuggestion__value'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeValue && activeValue.innerText) {
|
||||||
|
handleSelect(activeValue.innerText);
|
||||||
|
} else if (firstValue && firstValue.innerText) {
|
||||||
|
handleSelect(firstValue.innerText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleSelect, inputRef, suggestionValue]);
|
||||||
|
|
||||||
|
/** ------ **/
|
||||||
|
/** Render **/
|
||||||
|
/** ------ **/
|
||||||
|
|
||||||
|
const suggestionsRow = (label: string, suggestions: any) => (
|
||||||
|
<div className="textareaSuggestions__row">
|
||||||
|
<div className="textareaSuggestions__label">{label}</div>
|
||||||
|
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<TextareaSuggestionsItem key={suggestion} uri={suggestion} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<hr className="textareaSuggestions__topSeparator" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox onSelect={handleSelect} aria-label={name}>
|
||||||
|
{/* Regular Textarea Field */}
|
||||||
|
<ComboboxInput
|
||||||
|
{...inputProps}
|
||||||
|
value={commentValue}
|
||||||
|
as="textarea"
|
||||||
|
id={name}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onChange={(e) => handleChange(e)}
|
||||||
|
ref={inputRef}
|
||||||
|
selectOnClick
|
||||||
|
type={type}
|
||||||
|
autocomplete={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Possible Suggestions Box */}
|
||||||
|
{suggestionValue && isUriFromTermValid && (
|
||||||
|
<ComboboxPopover persistSelection className="textarea__suggestions">
|
||||||
|
<ComboboxList ref={comboboxListRef}>
|
||||||
|
{creatorUriMatch && creatorUriMatch.length > 0 && suggestionsRow(__('Creator'), creatorUriMatch)}
|
||||||
|
{subscriptionsMatch && subscriptionsMatch.length > 0 && suggestionsRow(__('Following'), subscriptionsMatch)}
|
||||||
|
{commentorsMatch && commentorsMatch.length > 0 && suggestionsRow(__('From comments'), commentorsMatch)}
|
||||||
|
|
||||||
|
{hasMinSearchLength &&
|
||||||
|
(showPlaceholder ? (
|
||||||
|
<Spinner type="small" />
|
||||||
|
) : (
|
||||||
|
results && (
|
||||||
|
<>
|
||||||
|
{!noTopSuggestion && (
|
||||||
|
<TextareaTopSuggestion
|
||||||
|
query={debouncedTerm}
|
||||||
|
filteredTop={filteredTop}
|
||||||
|
setTopSuggestion={setTopSuggestion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filteredSearch && filteredSearch.length > 0 && suggestionsRow(__('From search'), filteredSearch)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxPopover>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSuggestionMatch(term: string, list: Array<string>) {
|
||||||
|
const throttledTerm = useThrottle(term);
|
||||||
|
|
||||||
|
return React.useMemo(() => {
|
||||||
|
return !throttledTerm || throttledTerm.trim() === ''
|
||||||
|
? undefined
|
||||||
|
: matchSorter(list, term, { keys: [(item) => item] });
|
||||||
|
}, [list, term, throttledTerm]);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const useEffectOnce = effect => {
|
const useEffectOnce = (effect) => {
|
||||||
React.useEffect(effect, []);
|
React.useEffect(effect, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ function useUnmount(fn: () => any): void {
|
||||||
useEffectOnce(() => () => fnRef.current());
|
useEffectOnce(() => () => fnRef.current());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThrottle(value: string, ms: number = 200) {
|
export default function useThrottle(value: string, ms: number = 200) {
|
||||||
const [state, setState] = React.useState(value);
|
const [state, setState] = React.useState(value);
|
||||||
const timeout = React.useRef();
|
const timeout = React.useRef();
|
||||||
const nextValue = React.useRef(null);
|
const nextValue = React.useRef(null);
|
||||||
|
@ -37,7 +37,7 @@ export function useThrottle(value: string, ms: number = 200) {
|
||||||
nextValue.current = value;
|
nextValue.current = value;
|
||||||
hasNextValue.current = true;
|
hasNextValue.current = true;
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [ms, value]);
|
||||||
|
|
||||||
useUnmount(() => {
|
useUnmount(() => {
|
||||||
timeout.current && clearTimeout(timeout.current);
|
timeout.current && clearTimeout(timeout.current);
|
||||||
|
@ -45,5 +45,3 @@ export function useThrottle(value: string, ms: number = 200) {
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useThrottle;
|
|
||||||
|
|
|
@ -9,8 +9,10 @@ import {
|
||||||
selectMyClaimIdsRaw,
|
selectMyClaimIdsRaw,
|
||||||
selectMyChannelClaimIds,
|
selectMyChannelClaimIds,
|
||||||
selectClaimIdForUri,
|
selectClaimIdForUri,
|
||||||
|
selectClaimIdsByUri,
|
||||||
} from 'redux/selectors/claims';
|
} from 'redux/selectors/claims';
|
||||||
import { isClaimNsfw } from 'util/claim';
|
import { isClaimNsfw } from 'util/claim';
|
||||||
|
import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
|
||||||
|
|
||||||
type State = { claims: any, comments: CommentsState };
|
type State = { claims: any, comments: CommentsState };
|
||||||
|
|
||||||
|
@ -388,3 +390,47 @@ export const selectSuperChatTotalAmountForUri = (state: State, uri: string) => {
|
||||||
const superChatData = selectSuperChatDataForUri(state, uri);
|
const superChatData = selectSuperChatDataForUri(state, uri);
|
||||||
return superChatData ? superChatData.totalAmount : 0;
|
return superChatData ? superChatData.totalAmount : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const selectChannelMentionData = createCachedSelector(
|
||||||
|
selectClaimIdsByUri,
|
||||||
|
selectClaimsById,
|
||||||
|
selectTopLevelCommentsForUri,
|
||||||
|
selectSubscriptionUris,
|
||||||
|
(claimIdsByUri, claimsById, topLevelComments, subscriptionUris) => {
|
||||||
|
const commentorUris = [];
|
||||||
|
const canonicalCommentors = [];
|
||||||
|
const canonicalSubscriptions = [];
|
||||||
|
|
||||||
|
topLevelComments.forEach((comment) => {
|
||||||
|
const uri = comment.channel_url;
|
||||||
|
|
||||||
|
if (!commentorUris.includes(uri)) {
|
||||||
|
// Update: commentorUris
|
||||||
|
commentorUris.push(uri);
|
||||||
|
|
||||||
|
// Update: canonicalCommentors
|
||||||
|
const claimId = claimIdsByUri[uri];
|
||||||
|
const claim = claimsById[claimId];
|
||||||
|
if (claim && claim.canonical_url) {
|
||||||
|
canonicalCommentors.push(claim.canonical_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriptionUris.forEach((uri) => {
|
||||||
|
// Update: canonicalSubscriptions
|
||||||
|
const claimId = claimIdsByUri[uri];
|
||||||
|
const claim = claimsById[claimId];
|
||||||
|
if (claim && claim.canonical_url) {
|
||||||
|
canonicalSubscriptions.push(claim.canonical_url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
topLevelComments,
|
||||||
|
commentorUris,
|
||||||
|
canonicalCommentors,
|
||||||
|
canonicalSubscriptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)((state, uri, maxCount) => `${String(uri)}:${maxCount}`);
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { createCachedSelector } from 're-reselect';
|
import { createCachedSelector } from 're-reselect';
|
||||||
import { selectMyClaims, selectPendingClaims, selectClaimIdsByUri, selectClaimsById } from 'redux/selectors/claims';
|
import { selectMyClaims, selectPendingClaims } from 'redux/selectors/claims';
|
||||||
import { selectTopLevelCommentsForUri } from 'redux/selectors/comments';
|
|
||||||
import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
|
|
||||||
|
|
||||||
type State = { livestream: any };
|
type State = { livestream: any };
|
||||||
|
|
||||||
|
@ -63,67 +61,3 @@ export const selectIsActiveLivestreamForUri = createCachedSelector(
|
||||||
return activeLivestreamValues.some((v) => v.latestClaimUri === uri);
|
return activeLivestreamValues.some((v) => v.latestClaimUri === uri);
|
||||||
}
|
}
|
||||||
)((state, uri) => String(uri));
|
)((state, uri) => String(uri));
|
||||||
|
|
||||||
// ****************************************************************************
|
|
||||||
// Temporary
|
|
||||||
// ****************************************************************************
|
|
||||||
|
|
||||||
// Until ChannelMentions is redesigned, this serves as a cached and leaner
|
|
||||||
// version of the original code.
|
|
||||||
export const selectChannelMentionData = createCachedSelector(
|
|
||||||
selectClaimIdsByUri,
|
|
||||||
selectClaimsById,
|
|
||||||
selectTopLevelCommentsForUri,
|
|
||||||
selectSubscriptionUris,
|
|
||||||
(claimIdsByUri, claimsById, topLevelComments, subscriptionUris) => {
|
|
||||||
const commentorUris = [];
|
|
||||||
const unresolvedCommentors = [];
|
|
||||||
const unresolvedSubscriptions = [];
|
|
||||||
const canonicalCommentors = [];
|
|
||||||
const canonicalSubscriptions = [];
|
|
||||||
|
|
||||||
topLevelComments.forEach((comment) => {
|
|
||||||
const uri = comment.channel_url;
|
|
||||||
|
|
||||||
if (!commentorUris.includes(uri)) {
|
|
||||||
// Update: commentorUris
|
|
||||||
commentorUris.push(uri);
|
|
||||||
|
|
||||||
// Update: unresolvedCommentors
|
|
||||||
const claimId = claimIdsByUri[uri];
|
|
||||||
if (claimId === undefined) {
|
|
||||||
unresolvedCommentors.push(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update: canonicalCommentors
|
|
||||||
const claim = claimsById[claimId];
|
|
||||||
if (claim && claim.canonical_url) {
|
|
||||||
canonicalCommentors.push(claim.canonical_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriptionUris.forEach((uri) => {
|
|
||||||
// Update: unresolvedSubscriptions
|
|
||||||
const claimId = claimIdsByUri[uri];
|
|
||||||
if (claimId === undefined) {
|
|
||||||
unresolvedSubscriptions.push(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update: canonicalSubscriptions
|
|
||||||
const claim = claimsById[claimId];
|
|
||||||
if (claim && claim.canonical_url) {
|
|
||||||
canonicalSubscriptions.push(claim.canonical_url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
topLevelComments,
|
|
||||||
commentorUris,
|
|
||||||
unresolvedCommentors,
|
|
||||||
canonicalCommentors,
|
|
||||||
unresolvedSubscriptions,
|
|
||||||
canonicalSubscriptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)((state, uri, maxCount) => `${String(uri)}:${maxCount}`);
|
|
||||||
|
|
|
@ -14,7 +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/_textarea-suggestions';
|
||||||
@import 'component/claim-list';
|
@import 'component/claim-list';
|
||||||
@import 'component/collection';
|
@import 'component/collection';
|
||||||
@import 'component/comments';
|
@import 'component/comments';
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
|
@ -3,7 +3,7 @@
|
||||||
$thumbnailWidth: 1.5rem;
|
$thumbnailWidth: 1.5rem;
|
||||||
$thumbnailWidthSmall: 1rem;
|
$thumbnailWidthSmall: 1rem;
|
||||||
|
|
||||||
.content_comment {
|
.create___comment {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
78
ui/scss/component/_textarea-suggestions.scss
Normal file
78
ui/scss/component/_textarea-suggestions.scss
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
.textarea__suggestions {
|
||||||
|
@extend .card;
|
||||||
|
background-color: var(--color-card-background);
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
box-shadow: var(--card-box-shadow);
|
||||||
|
|
||||||
|
> .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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestions__label:first-of-type {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestion {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestions__row {
|
||||||
|
&:last-child hr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestions__topSeparator {
|
||||||
|
@extend .wunderbar__top-separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestion__label {
|
||||||
|
@extend .wunderbar__suggestion-label;
|
||||||
|
margin-left: var(--spacing-m);
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestions__label {
|
||||||
|
@extend .wunderbar__label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestions__topSeparator {
|
||||||
|
@extend .wunderbar__top-separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestion__value {
|
||||||
|
@extend .wunderbar__suggestion-name;
|
||||||
|
margin-left: calc(var(--spacing-l) - var(--spacing-xxs));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaSuggestion__title {
|
||||||
|
@extend .wunderbar__suggestion-title;
|
||||||
|
margin-left: calc(var(--spacing-l) - var(--spacing-xxs));
|
||||||
|
}
|
Loading…
Reference in a new issue