-
Notifications
You must be signed in to change notification settings - Fork 414
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Channel Mention selection ability (#7151)
* 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
1 parent
81abae8
commit 2f4dedf
Showing
19 changed files
with
737 additions
and
74 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
Oops, something went wrong.