Skip to content

Commit

Permalink
Add Channel Mention selection ability (#7151)
Browse files Browse the repository at this point in the history
* Add Channel Mention selection ability

* Fix mentioned user name being smaller than other text

* Improve logic for locating a mention

* Fix mentioning with enter on livestream

* Fix breaking for invalid URI query

* Handle punctuation after mention

* Fix name display and appeareance

* Use canonical url

* Fix missing search
  • Loading branch information
rafael-xmr authored Sep 30, 2021
1 parent 81abae8 commit 2f4dedf
Show file tree
Hide file tree
Showing 19 changed files with 737 additions and 74 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added "Shuffle" option on Lists ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added Play Next/Previous buttons (with shortcuts SHIFT+N/SHIFT+P) ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added separate control for autoplay next on video player ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added Channel Mention selection ability while creating a comment ([#7151](https://github.com/lbryio/lbry-desktop/pull/7151))

### Changed
- Changing the supported language from Filipino to Tagalog _community pr!_ ([#6951](https://github.com/lbryio/lbry-desktop/pull/6951))
Expand Down
10 changes: 10 additions & 0 deletions ui/component/channelMentionSuggestion/index.js
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions ui/component/channelMentionSuggestion/view.jsx
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions ui/component/channelMentionSuggestions/index.js
Original file line number Diff line number Diff line change
@@ -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 changes: 285 additions & 0 deletions ui/component/channelMentionSuggestions/view.jsx
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions ui/component/channelMentionTopSuggestion/index.js
Original file line number Diff line number Diff line change
@@ -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);
Loading

0 comments on commit 2f4dedf

Please sign in to comment.