({
- 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..40151c761 100644
--- a/ui/component/commentCreate/view.jsx
+++ b/ui/component/commentCreate/view.jsx
@@ -1,40 +1,39 @@
// @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();
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
+const MENTION_DEBOUNCE_MS = 100;
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 +43,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 +72,64 @@ 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 formFieldInputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input;
+ const selectionIndex = formFieldInputRef && formFieldInputRef.current && formFieldInputRef.current.selectionStart;
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 [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 [pauseQuickSend, setPauseQuickSend] = React.useState(false);
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
+
+ const selectedMentionIndex =
+ commentValue.indexOf('@', selectionIndex) === selectionIndex
+ ? commentValue.indexOf('@', selectionIndex)
+ : commentValue.lastIndexOf('@', selectionIndex);
+ const modifierIndex = commentValue.indexOf(':', selectedMentionIndex);
+ const spaceIndex = commentValue.indexOf(' ', selectedMentionIndex);
+ const mentionLengthIndex =
+ modifierIndex >= 0 && (spaceIndex === -1 || modifierIndex < spaceIndex)
+ ? modifierIndex
+ : spaceIndex >= 0 && (modifierIndex === -1 || spaceIndex < modifierIndex)
+ ? spaceIndex
+ : commentValue.length;
+ const channelMention =
+ selectedMentionIndex >= 0 && selectionIndex <= mentionLengthIndex
+ ? commentValue.substring(selectedMentionIndex, mentionLengthIndex)
+ : '';
+
+ const claimId = claim && claim.claim_id;
+ const signingChannel = (claim && claim.signing_channel) || claim;
+ const channelUri = signingChannel && signingChannel.permanent_url;
+ const hasChannels = channels && channels.length;
+ const charCount = commentValue ? commentValue.length : 0;
+ const disabled = deletedComment || isSubmitting || isFetchingChannels || !commentValue.length || pauseQuickSend;
const channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
@@ -109,15 +137,6 @@ export function CommentCreate(props: Props) {
const minAmount = minTip || minSuper || 0;
const 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 +176,20 @@ export function CommentCreate(props: Props) {
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<*>) {
if ((livestream || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
e.preventDefault();
@@ -365,10 +398,6 @@ export function CommentCreate(props: Props) {
});
}
- function toggleEditorMode() {
- setAdvancedEditor(!advancedEditor);
- }
-
// **************************************************************************
// Effects
// **************************************************************************
@@ -378,7 +407,28 @@ 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]);
+
+ // Debounce for disabling the submit button when mentioning a user with Enter
+ // so that the comment isn't sent at the same time
+ React.useEffect(() => {
+ const timer = setTimeout(() => {
+ if (pauseQuickSend) {
+ setPauseQuickSend(false);
+ }
+ }, MENTION_DEBOUNCE_MS);
+
+ return () => clearTimeout(timer);
+ }, [pauseQuickSend]);
// **************************************************************************
// Render
@@ -466,10 +516,22 @@ export function CommentCreate(props: Props) {
'comment__create--bottom': bottom,
})}
>
+ {!advancedEditor && (
+
+ )}
{!livestream && (
@@ -481,7 +543,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..a10b11c4a
--- /dev/null
+++ b/ui/scss/component/_channel-mention.scss
@@ -0,0 +1,125 @@
+.channel-mention {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ bottom: calc(100% - 1.8rem);
+ z-index: 3;
+ font-size: var(--font-small);
+ padding-left: var(--spacing-s);
+
+ > .icon {
+ top: 0;
+ left: var(--spacing-m);
+ height: 100%;
+ position: absolute;
+ z-index: 1;
+ stroke: var(--color-input-placeholder);
+ }
+
+ @media (min-width: $breakpoint-small) {
+ padding: 0;
+ }
+}
+
+.channel-mention__suggestions {
+ @extend .card;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ max-height: 30vh;
+ position: absolute;
+ text-overflow: ellipsis;
+ width: 22rem;
+ z-index: 3;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ box-shadow: var(--card-box-shadow);
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ border-bottom: none;
+
+ .channel-mention__label:first-of-type {
+ margin-top: var(--spacing-xs);
+ }
+}
+
+.channel-mention__suggestions[flow-bottom] {
+ top: 4rem;
+ bottom: auto;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ border-top: none;
+ border-bottom-right-radius: var(--border-radius);
+ border-bottom-left-radius: var(--border-radius);
+ border-bottom: auto;
+}
+
+.channel-mention__input--none {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.channel-mention__label {
+ @extend .wunderbar__label;
+}
+
+.channel-mention__top-separator {
+ @extend .wunderbar__top-separator;
+}
+
+.channel-mention__suggestion {
+ display: flex;
+ align-items: center;
+ padding: 0 var(--spacing-xxs);
+ margin-left: var(--spacing-xxs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .channel-thumbnail {
+ @include handleChannelGif(2.1rem);
+ position: absolute;
+
+ @media (min-width: $breakpoint-small) {
+ @include handleChannelGif(2.1rem);
+ }
+ }
+}
+
+.channel-mention__suggestion-label {
+ @extend .wunderbar__suggestion-label;
+ margin-left: var(--spacing-m);
+ display: block;
+ position: relative;
+}
+
+.channel-mention__suggestion-name {
+ @extend .wunderbar__suggestion-name;
+ margin-left: calc(var(--spacing-l) - var(--spacing-xxs));
+}
+
+.channel-mention__suggestion-title {
+ @extend .wunderbar__suggestion-title;
+ margin-left: calc(var(--spacing-l) - var(--spacing-xxs));
+}
+
+.channel-mention__placeholder-suggestion {
+ @extend .wunderbar__placeholder-suggestion;
+ padding: 0 var(--spacing-xxs);
+ margin-left: var(--spacing-xxs);
+}
+
+.channel-mention__placeholder-label {
+ @extend .wunderbar__placeholder-label;
+ margin-left: var(--spacing-m);
+}
+
+.channel-mention__placeholder-thumbnail {
+ @extend .wunderbar__placeholder-thumbnail;
+ margin-left: var(--spacing-m);
+}
+.channel-mention__placeholder-info {
+ @extend .wunderbar__placeholder-info;
+ margin-left: var(--spacing-m);
+}
diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss
index d63cbab57..1beab514c 100644
--- a/ui/scss/component/_comments.scss
+++ b/ui/scss/component/_comments.scss
@@ -34,6 +34,16 @@ $thumbnailWidthSmall: 1rem;
.comment__create {
font-size: var(--font-small);
+ position: relative;
+
+ fieldset-section,
+ .form-field--SimpleMDE {
+ margin-top: 0;
+ }
+
+ .form-field__two-column {
+ column-count: 2;
+ }
}
.comment__create--reply {
@@ -80,6 +90,10 @@ $thumbnailWidthSmall: 1rem;
}
}
+.content_comment {
+ position: relative;
+}
+
.comment__thumbnail-wrapper {
flex: 0;
margin-top: var(--spacing-xxs);
diff --git a/ui/scss/component/_livestream.scss b/ui/scss/component/_livestream.scss
index 6e6c6104f..20e9559c7 100644
--- a/ui/scss/component/_livestream.scss
+++ b/ui/scss/component/_livestream.scss
@@ -359,6 +359,10 @@ $recent-msg-button__height: 2rem;
p {
word-break: break-word;
}
+
+ .channel-name {
+ font-size: var(--font-small);
+ }
}
}
diff --git a/ui/util/remark-lbry.js b/ui/util/remark-lbry.js
index 2a2fb1483..fc8bb17e6 100644
--- a/ui/util/remark-lbry.js
+++ b/ui/util/remark-lbry.js
@@ -3,6 +3,7 @@ import visit from 'unist-util-visit';
const protocol = 'lbry://';
const uriRegex = /(lbry:\/\/)[^\s"]*[^)]/g;
+const punctuationMarks = [',', '.', '!', '?', ':', ';', '-', ']', ')', '}'];
const mentionToken = '@';
// const mentionTokenCode = 64; // @
@@ -10,9 +11,24 @@ const mentionRegex = /@[^\s()"]*/gm;
const invalidRegex = /[-_.+=?!@#$%^&*:;,{}<>\w/\\]/;
+function handlePunctuation(value) {
+ const modifierIndex =
+ (value.indexOf(':') >= 0 && value.indexOf(':')) || (value.indexOf('#') >= 0 && value.indexOf('#'));
+
+ let punctuationIndex;
+ punctuationMarks.some((p) => {
+ if (modifierIndex) {
+ punctuationIndex = value.indexOf(p, modifierIndex + 1) >= 0 && value.indexOf(p, modifierIndex + 1);
+ }
+ return punctuationIndex;
+ });
+
+ return punctuationIndex ? value.substring(0, punctuationIndex) : value;
+}
+
// Find channel mention
function locateMention(value, fromIndex) {
- var index = value.indexOf(mentionToken, fromIndex);
+ const index = value.indexOf(mentionToken, fromIndex);
// Skip invalid mention
if (index > 0 && invalidRegex.test(value.charAt(index - 1))) {
@@ -45,21 +61,22 @@ const createURI = (text, uri, embed = false) => ({
children: [{ type: 'text', value: text }],
});
-const validateURI = (match, eat, self) => {
+const validateURI = (match, eat) => {
if (match) {
try {
const text = match[0];
- const uri = parseURI(text);
+ const newText = handlePunctuation(text);
+ const uri = parseURI(newText);
const isValid = uri && uri.claimName;
const isChannel = uri.isChannel && uri.path === uri.claimName;
if (isValid) {
// Create channel link
if (isChannel) {
- return eat(text)(createURI(uri.claimName, text, false));
+ return eat(newText)(createURI(uri.claimName, newText, false));
}
// Create claim link
- return eat(text)(createURI(text, text, true));
+ return eat(newText)(createURI(newText, newText, true));
}
} catch (err) {
// Silent errors: console.error(err)
@@ -128,7 +145,7 @@ const visitor = (node, index, parent) => {
};
// transform
-const transform = tree => {
+const transform = (tree) => {
visit(tree, ['link'], visitor);
};
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';