diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d470fd4..39f6715a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/ui/component/channelMentionSuggestion/index.js b/ui/component/channelMentionSuggestion/index.js new file mode 100644 index 000000000..40b1a92ae --- /dev/null +++ b/ui/component/channelMentionSuggestion/index.js @@ -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); diff --git a/ui/component/channelMentionSuggestion/view.jsx b/ui/component/channelMentionSuggestion/view.jsx new file mode 100644 index 000000000..4bc02300b --- /dev/null +++ b/ui/component/channelMentionSuggestion/view.jsx @@ -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 : ( + + {isResolvingUri ? ( + + + + ) : ( + + + + {claim.name} + {(claim.value && claim.value.title) || claim.name} + + + )} + + ); +} diff --git a/ui/component/channelMentionSuggestions/index.js b/ui/component/channelMentionSuggestions/index.js new file mode 100644 index 000000000..188bdf7e5 --- /dev/null +++ b/ui/component/channelMentionSuggestions/index.js @@ -0,0 +1,28 @@ +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); + + let commentorUris = []; + 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); + + return { + commentorUris, + subscriptionUris, + unresolvedCommentors: getUnresolved(commentorUris), + unresolvedSubscriptions: getUnresolved(subscriptionUris), + showMature: selectShowMatureContent(state), + }; +}; + +export default withRouter(connect(select, { doResolveUris })(ChannelMentionSuggestions)); diff --git a/ui/component/channelMentionSuggestions/view.jsx b/ui/component/channelMentionSuggestions/view.jsx new file mode 100644 index 000000000..ad4a3388d --- /dev/null +++ b/ui/component/channelMentionSuggestions/view.jsx @@ -0,0 +1,220 @@ +// @flow +import { Combobox, ComboboxInput, ComboboxPopover, ComboboxList } from '@reach/combobox'; +import { Form } from 'component/common/form'; +import { parseURI } 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, + creatorUri: string, + isLivestream: boolean, + commentorUris: Array, + unresolvedCommentors: Array, + subscriptionUris: Array, + unresolvedSubscriptions: Array, + doResolveUris: (Array) => void, + customSelectAction?: (string) => void, +}; + +export default function ChannelMentionSuggestions(props: Props) { + const { + unresolvedCommentors, + unresolvedSubscriptions, + isLivestream, + creatorUri, + inputRef, + showMature, + noTopSuggestion, + mentionTerm, + doResolveUris, + customSelectAction, + } = props; + const comboboxInputRef: ElementRef = React.useRef(); + const comboboxListRef: ElementRef = React.useRef(); + const [debouncedTerm, setDebouncedTerm] = React.useState(''); + + const isRefFocused = (ref) => ref && ref.current && ref.current === document.activeElement; + + let subscriptionUris = props.subscriptionUris.filter((uri) => uri !== creatorUri); + let commentorUris = props.commentorUris.filter((uri) => uri !== creatorUri && !subscriptionUris.includes(uri)); + + const termToMatch = mentionTerm && mentionTerm.replace('@', '').toLowerCase(); + const allShownUris = [creatorUri, ...subscriptionUris, ...commentorUris]; + const possibleMatches = allShownUris.filter((uri) => { + try { + const { channelName } = parseURI(uri); + return channelName.toLowerCase().includes(termToMatch); + } catch (e) {} + }); + const hasSubscriptionsResolved = + subscriptionUris && + !subscriptionUris.every((uri) => unresolvedSubscriptions && unresolvedSubscriptions.includes(uri)); + const hasCommentorsShown = + commentorUris.length > 0 && commentorUris.some((uri) => possibleMatches && possibleMatches.includes(uri)); + + 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 handleSelect = React.useCallback( + (value) => { + if (customSelectAction) { + // Give them full results, as our resolved one might truncate the claimId. + customSelectAction(value || (results && results.find((r) => r.startsWith(value))) || ''); + } + if (inputRef && inputRef.current) inputRef.current.focus(); + }, + [customSelectAction, inputRef, results] + ); + + React.useEffect(() => { + const timer = setTimeout(() => { + if (isTyping) setDebouncedTerm(!hasMinLength ? '' : mentionTerm); + }, INPUT_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [isTyping, mentionTerm, hasMinLength, possibleMatches.length]); + + React.useEffect(() => { + if (!inputRef) return; + + if (mentionTerm) { + inputRef.current.classList.add('textarea-mention'); + } else { + inputRef.current.classList.remove('textarea-mention'); + } + }, [inputRef, mentionTerm]); + + 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 { + comboboxInputRef.current.focus(); + } + } else { + if (keyCode === KEYCODES.TAB) { + event.preventDefault(); + const activeValue = activeElement && activeElement.getAttribute('value'); + + if (activeValue) { + handleSelect(activeValue); + } else if (possibleMatches.length) { + handleSelect(possibleMatches[0]); + } else if (results) { + handleSelect(mentionTerm); + } + } + inputRef.current.focus(); + } + } + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleSelect, inputRef, mentionTerm, possibleMatches, results]); + + React.useEffect(() => { + if (!stringifiedResults) return; + + const arrayResults = JSON.parse(stringifiedResults); + if (arrayResults && arrayResults.length > 0) doResolveUris(arrayResults); + }, [doResolveUris, stringifiedResults]); + + // Only resolve commentors on Livestreams if 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; + + let 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, hasSuggestionsBelow: boolean) => { + if (mentionTerm !== '@' && suggestions !== results) { + suggestions = suggestions.filter((uri) => possibleMatches.includes(uri)); + } else if (suggestions === results) { + suggestions = suggestions.filter((uri) => !allShownUris.includes(uri)); + } + + return !suggestions.length ? null : ( + <> + {label} + {suggestions.map((uri) => ( + + ))} + {hasSuggestionsBelow && } + > + ); + }; + + return ( + handleSelect(mentionTerm)}> + + + {mentionTerm && ( + + + {creatorUri && + suggestionsRow( + __('Creator'), + [creatorUri], + hasSubscriptionsResolved || hasCommentorsShown || !showPlaceholder + )} + {hasSubscriptionsResolved && + suggestionsRow(__('Following'), subscriptionUris, hasCommentorsShown || !showPlaceholder)} + {commentorUris.length > 0 && suggestionsRow(__('From comments'), commentorUris, !showPlaceholder)} + + {showPlaceholder + ? hasMinLength && + : results && ( + <> + {!noTopSuggestion && } + {suggestionsRow(__('From search'), results, false)} + > + )} + + + )} + + + ); +} diff --git a/ui/component/channelMentionTopSuggestion/index.js b/ui/component/channelMentionTopSuggestion/index.js new file mode 100644 index 000000000..d3a1d55b5 --- /dev/null +++ b/ui/component/channelMentionTopSuggestion/index.js @@ -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); diff --git a/ui/component/channelMentionTopSuggestion/view.jsx b/ui/component/channelMentionTopSuggestion/view.jsx new file mode 100644 index 000000000..7568ee4a4 --- /dev/null +++ b/ui/component/channelMentionTopSuggestion/view.jsx @@ -0,0 +1,43 @@ +// @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, + doResolveUri: (string) => void, +}; + +export default function ChannelMentionTopSuggestion(props: Props) { + const { uriFromQuery, winningUri, isResolvingUri, doResolveUri } = props; + + React.useEffect(() => { + if (uriFromQuery) doResolveUri(uriFromQuery); + }, [doResolveUri, uriFromQuery]); + + if (isResolvingUri) { + return ( + + + + + + + + + + ); + } + + return !winningUri ? null : ( + <> + + + + + + > + ); +} diff --git a/ui/component/commentCreate/index.js b/ui/component/commentCreate/index.js index 2f6d25b47..29108cc64 100644 --- a/ui/component/commentCreate/index.js +++ b/ui/component/commentCreate/index.js @@ -6,16 +6,13 @@ import { selectFetchingMyChannels, doSendTip, } from 'lbry-redux'; -import { doOpenModal, doSetActiveChannel } from 'redux/actions/app'; import { doCommentCreate, doFetchCreatorSettings, doCommentById } from 'redux/actions/comments'; -import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectSettingsByChannelId } from 'redux/selectors/comments'; import { CommentCreate } from './view'; import { doToast } from 'redux/actions/notifications'; const select = (state, props) => ({ - commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, claim: makeSelectClaimForUri(props.uri)(state), channels: selectMyChannelClaims(state), isFetchingChannels: selectFetchingMyChannels(state), @@ -38,8 +35,6 @@ const perform = (dispatch, ownProps) => ({ environment ) ), - openModal: (modal, props) => dispatch(doOpenModal(modal, props)), - setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)), sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)), doToast: (options) => dispatch(doToast(options)), doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)), diff --git a/ui/component/commentCreate/view.jsx b/ui/component/commentCreate/view.jsx index 91cd44875..d27b21af3 100644 --- a/ui/component/commentCreate/view.jsx +++ b/ui/component/commentCreate/view.jsx @@ -1,26 +1,27 @@ // @flow -import type { ElementRef } from 'react'; -import { SIMPLE_SITE } from 'config'; -import * as PAGES from 'constants/pages'; -import * as ICONS from 'constants/icons'; -import * as KEYCODES from 'constants/keycodes'; -import React from 'react'; -import classnames from 'classnames'; -import { FormField, Form } from 'component/common/form'; -import Icon from 'component/common/icon'; -import Button from 'component/button'; -import SelectChannel from 'component/selectChannel'; -import usePersistedState from 'effects/use-persisted-state'; import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field'; -import { useHistory } from 'react-router'; -import WalletTipAmountSelector from 'component/walletTipAmountSelector'; -import CreditAmount from 'component/common/credit-amount'; -import ChannelThumbnail from 'component/channelThumbnail'; -import I18nMessage from 'component/i18nMessage'; -import UriIndicator from 'component/uriIndicator'; -import Empty from 'component/common/empty'; +import { FormField, Form } from 'component/common/form'; import { getChannelIdFromClaim } from 'util/claim'; import { Lbryio } from 'lbryinc'; +import { SIMPLE_SITE } from 'config'; +import { useHistory } from 'react-router'; +import * as ICONS from 'constants/icons'; +import * as KEYCODES from 'constants/keycodes'; +import * as PAGES from 'constants/pages'; +import Button from 'component/button'; +import ChannelMentionSuggestions from 'component/channelMentionSuggestions'; +import ChannelThumbnail from 'component/channelThumbnail'; +import classnames from 'classnames'; +import CreditAmount from 'component/common/credit-amount'; +import Empty from 'component/common/empty'; +import I18nMessage from 'component/i18nMessage'; +import Icon from 'component/common/icon'; +import React from 'react'; +import SelectChannel from 'component/selectChannel'; +import type { ElementRef } from 'react'; +import UriIndicator from 'component/uriIndicator'; +import usePersistedState from 'effects/use-persisted-state'; +import WalletTipAmountSelector from 'component/walletTipAmountSelector'; import { getStripeEnvironment } from 'util/stripe'; let stripeEnvironment = getStripeEnvironment(); @@ -31,10 +32,7 @@ const TAB_LBC = 'TabLBC'; type Props = { uri: string, claim: StreamClaim, - createComment: (string, string, string, ?string, ?string, ?string) => Promise, channels: ?Array, - onDoneReplying?: () => void, - onCancelReplying?: () => void, isNested: boolean, isFetchingChannels: boolean, parentId: string, @@ -44,25 +42,26 @@ type Props = { bottom: boolean, livestream?: boolean, embed?: boolean, - toast: (string) => void, claimIsMine: boolean, - sendTip: ({}, (any) => void, (any) => void) => void, - doToast: ({ message: string }) => void, supportDisabled: boolean, - doFetchCreatorSettings: (channelId: string) => Promise, settingsByChannelId: { [channelId: string]: PerChannelSettings }, + shouldFetchComment: boolean, + doToast: ({ message: string }) => void, + createComment: (string, string, string, ?string, ?string, ?string) => Promise, + onDoneReplying?: () => void, + onCancelReplying?: () => void, + toast: (string) => void, + sendTip: ({}, (any) => void, (any) => void) => void, + doFetchCreatorSettings: (channelId: string) => Promise, setQuickReply: (any) => void, fetchComment: (commentId: string) => Promise, - shouldFetchComment: boolean, }; export function CommentCreate(props: Props) { const { - createComment, + uri, claim, channels, - onDoneReplying, - onCancelReplying, isNested, isFetchingChannels, isReply, @@ -72,36 +71,47 @@ export function CommentCreate(props: Props) { livestream, embed, claimIsMine, - sendTip, - doToast, - doFetchCreatorSettings, settingsByChannelId, supportDisabled, + shouldFetchComment, + doToast, + createComment, + onDoneReplying, + onCancelReplying, + sendTip, + doFetchCreatorSettings, setQuickReply, fetchComment, - shouldFetchComment, } = props; + const formFieldRef: ElementRef = React.useRef(); const buttonRef: ElementRef = React.useRef(); const { push, location: { pathname }, } = useHistory(); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [commentFailure, setCommentFailure] = React.useState(false); const [successTip, setSuccessTip] = React.useState({ txid: undefined, tipAmount: undefined }); - const claimId = claim && claim.claim_id; const [isSupportComment, setIsSupportComment] = React.useState(); const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState(); const [tipAmount, setTipAmount] = React.useState(1); const [commentValue, setCommentValue] = React.useState(''); + const [channelMention, setChannelMention] = React.useState(''); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); - const hasChannels = channels && channels.length; - const charCount = commentValue.length; const [activeTab, setActiveTab] = React.useState(''); const [tipError, setTipError] = React.useState(); const [deletedComment, setDeletedComment] = React.useState(false); - const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length; const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState(); + + const commentWords = commentValue ? commentValue && commentValue.split(' ') : []; + const lastCommentWord = commentWords && commentWords[commentWords.length - 1]; + const claimId = claim && claim.claim_id; + const signingChannel = (claim && claim.signing_channel) || claim; + const channelUri = signingChannel && signingChannel.permanent_url; + const hasChannels = channels && channels.length; + const charCount = commentValue ? commentValue.length : 0; + const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length; const channelId = getChannelIdFromClaim(claim); const channelSettings = channelId ? settingsByChannelId[channelId] : undefined; const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0; @@ -109,15 +119,6 @@ export function CommentCreate(props: Props) { const minAmount = minTip || minSuper || 0; const minAmountMet = minAmount === 0 || tipAmount >= minAmount; - // Fetch top-level comments to identify if it has been deleted and can reply to it - React.useEffect(() => { - if (shouldFetchComment && fetchComment) { - fetchComment(parentId).then((result) => { - setDeletedComment(String(result).includes('Error')); - }); - } - }, [fetchComment, shouldFetchComment, parentId]); - const minAmountRef = React.useRef(minAmount); minAmountRef.current = minAmount; @@ -157,6 +158,19 @@ export function CommentCreate(props: Props) { setCommentValue(commentValue); } + function handleSelectMention(mentionValue) { + const newCommentValue = commentWords.slice(0, -1).join(' '); + let newMentionValue = mentionValue; + if (mentionValue.includes('#')) { + newMentionValue = mentionValue + .substring(0, mentionValue.indexOf('#') + 3) + .replace('lbry://', '') + .replace('#', ':'); + } + + setCommentValue(newCommentValue + (commentWords.length > 1 ? ' ' : '') + `${newMentionValue} `); + } + function altEnterListener(e: SyntheticKeyboardEvent<*>) { if ((livestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) { e.preventDefault(); @@ -365,10 +379,6 @@ export function CommentCreate(props: Props) { }); } - function toggleEditorMode() { - setAdvancedEditor(!advancedEditor); - } - // ************************************************************************** // Effects // ************************************************************************** @@ -378,7 +388,21 @@ export function CommentCreate(props: Props) { if (!channelSettings && channelId) { doFetchCreatorSettings(channelId); } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [channelId, channelSettings, doFetchCreatorSettings]); + + // Notifications: Fetch top-level comments to identify if it has been deleted and can reply to it + React.useEffect(() => { + if (shouldFetchComment && fetchComment) { + fetchComment(parentId).then((result) => { + setDeletedComment(String(result).includes('Error')); + }); + } + }, [fetchComment, shouldFetchComment, parentId]); + + React.useEffect(() => { + const isMentioning = lastCommentWord && lastCommentWord.indexOf('@') === 0; + setChannelMention(isMentioning ? lastCommentWord : ''); + }, [lastCommentWord]); // ************************************************************************** // Render @@ -466,10 +490,22 @@ export function CommentCreate(props: Props) { 'comment__create--bottom': bottom, })} > + {!advancedEditor && ( + + )} {!livestream && ( @@ -481,7 +517,7 @@ export function CommentCreate(props: Props) { quickActionLabel={ !SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')) } - quickActionHandler={!SIMPLE_SITE && toggleEditorMode} + quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)} onFocus={onTextareaFocus} onBlur={onTextareaBlur} placeholder={__('Say something about this...')} diff --git a/ui/component/commentsList/view.jsx b/ui/component/commentsList/view.jsx index 181f9bad6..8235ebd75 100644 --- a/ui/component/commentsList/view.jsx +++ b/ui/component/commentsList/view.jsx @@ -278,6 +278,7 @@ function CommentList(props: Props) { return ( 0 ? totalComments === 1 diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 70e7aa195..7767f9e79 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -14,6 +14,7 @@ @import 'component/button'; @import 'component/card'; @import 'component/channel'; +@import 'component/channel-mention'; @import 'component/claim-list'; @import 'component/collection'; @import 'component/comments'; @@ -67,4 +68,3 @@ @import 'component/empty'; @import 'component/stripe-card'; @import 'component/wallet-tip-send'; - diff --git a/ui/scss/component/_channel-mention.scss b/ui/scss/component/_channel-mention.scss new file mode 100644 index 000000000..b4b6a339c --- /dev/null +++ b/ui/scss/component/_channel-mention.scss @@ -0,0 +1,115 @@ +.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__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 { + @extend .wunderbar__suggestion; + 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 { + display: inline; + margin-left: calc(var(--spacing-l) - var(--spacing-xxs)); + + &::after { + margin-left: var(--spacing-xxs); + content: '•'; + } +} + +.channel-mention__suggestion-title { + display: inline; + margin-left: var(--spacing-xxs); +} + +.channel-mention__placeholder-suggestion { + @extend .wunderbar__suggestion-name; +} + +.channel-mention__placeholder-label { + @extend .wunderbar__suggestion-name; +} + +.channel-mention__placeholder-thumbnail { + @extend .wunderbar__suggestion-name; +} +.channel-mention__placeholder-info { + @extend .wunderbar__suggestion-name; +} + +.textarea-mention { + color: var(--color-primary); +} diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss index d63cbab57..af0090796 100644 --- a/ui/scss/component/_comments.scss +++ b/ui/scss/component/_comments.scss @@ -34,6 +34,12 @@ $thumbnailWidthSmall: 1rem; .comment__create { font-size: var(--font-small); + position: relative; + + fieldset-section, + .form-field--SimpleMDE { + margin-top: 0; + } } .comment__create--reply { @@ -80,6 +86,10 @@ $thumbnailWidthSmall: 1rem; } } +.content_comment { + position: relative; +} + .comment__thumbnail-wrapper { flex: 0; margin-top: var(--spacing-xxs); diff --git a/web/scss/lbrytv.scss b/web/scss/lbrytv.scss index 4fbbe7f21..91b6201e5 100644 --- a/web/scss/lbrytv.scss +++ b/web/scss/lbrytv.scss @@ -15,6 +15,7 @@ @import '../../ui/scss/component/button'; @import '../../ui/scss/component/card'; @import '../../ui/scss/component/channel'; +@import '../../ui/scss/component/channel-mention'; @import '../../ui/scss/component/claim-list'; @import '../../ui/scss/component/collection'; @import '../../ui/scss/component/comments'; diff --git a/web/scss/odysee.scss b/web/scss/odysee.scss index fe9b4f2ed..1e2cf5ede 100644 --- a/web/scss/odysee.scss +++ b/web/scss/odysee.scss @@ -15,6 +15,7 @@ @import '../../ui/scss/component/button'; @import '../../ui/scss/component/card'; @import '../../ui/scss/component/channel'; +@import '../../ui/scss/component/channel-mention'; @import '../../ui/scss/component/claim-list'; @import '../../ui/scss/component/collection'; @import '../../ui/scss/component/comments';