diff --git a/package.json b/package.json index 82cd15397..4c8554656 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "imagesloaded": "^4.1.4", "json-loader": "^0.5.4", "lbry-format": "https://github.com/lbryio/lbry-format.git", - "lbry-redux": "lbryio/lbry-redux#ba5d6b84bec6bdb2f0a1a6b23e695212c65f650e", + "lbry-redux": "lbryio/lbry-redux#a08fc63fe2ee46383ae7e4beb11efe72522a1dd9", "lbryinc": "lbryio/lbryinc#db0663fcc4a64cb082b6edc5798fafa67eb4300f", "lint-staged": "^7.0.2", "localforage": "^1.7.1", diff --git a/static/app-strings.json b/static/app-strings.json index 15f72f963..c87e5a313 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1453,6 +1453,9 @@ "lbry.com": "lbry.com", "lbry.tech": "lbry.tech", "GitHub": "GitHub", + "Pin": "Pin", + "Unpin": "Unpin", + "LBRY leveled up": "LBRY leveled up", "This link leads to an external website.": "This link leads to an external website.", "--end--": "--end--" } diff --git a/ui/component/comment/index.js b/ui/component/comment/index.js index da3e15038..d40cac781 100644 --- a/ui/component/comment/index.js +++ b/ui/component/comment/index.js @@ -6,26 +6,39 @@ import { makeSelectThumbnailForUri, makeSelectIsUriResolving, selectMyChannelClaims, + makeSelectMyChannelPermUrlForName, + makeSelectChannelPermUrlForClaimUri, } from 'lbry-redux'; -import { doCommentAbandon, doCommentUpdate } from 'redux/actions/comments'; +import { doCommentAbandon, doCommentUpdate, doCommentPin, doCommentList } from 'redux/actions/comments'; import { doToggleBlockChannel } from 'redux/actions/blocked'; import { selectChannelIsBlocked } from 'redux/selectors/blocked'; import { doToast } from 'redux/actions/notifications'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; -import { selectIsFetchingComments, makeSelectOthersReactionsForComment } from 'redux/selectors/comments'; +import { + selectIsFetchingComments, + makeSelectOthersReactionsForComment, + selectCommentChannel, +} from 'redux/selectors/comments'; import Comment from './view'; -const select = (state, props) => ({ - pending: props.authorUri && makeSelectClaimIsPending(props.authorUri)(state), - channel: props.authorUri && makeSelectClaimForUri(props.authorUri)(state), - isResolvingUri: props.authorUri && makeSelectIsUriResolving(props.authorUri)(state), - thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), - channelIsBlocked: props.authorUri && selectChannelIsBlocked(props.authorUri)(state), - commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, - isFetchingComments: selectIsFetchingComments(state), - myChannels: selectMyChannelClaims(state), - othersReacts: makeSelectOthersReactionsForComment(props.commentId)(state), -}); +const select = (state, props) => { + const channel = selectCommentChannel(state); + + return { + activeChannel: channel, + pending: props.authorUri && makeSelectClaimIsPending(props.authorUri)(state), + channel: props.authorUri && makeSelectClaimForUri(props.authorUri)(state), + isResolvingUri: props.authorUri && makeSelectIsUriResolving(props.authorUri)(state), + thumbnail: props.authorUri && makeSelectThumbnailForUri(props.authorUri)(state), + channelIsBlocked: props.authorUri && selectChannelIsBlocked(props.authorUri)(state), + commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, + isFetchingComments: selectIsFetchingComments(state), + myChannels: selectMyChannelClaims(state), + othersReacts: makeSelectOthersReactionsForComment(props.commentId)(state), + commentIdentityChannel: makeSelectMyChannelPermUrlForName(channel)(state), + contentChannel: makeSelectChannelPermUrlForClaimUri(props.uri)(state), + }; +}; const perform = dispatch => ({ resolveUri: uri => dispatch(doResolveUri(uri)), @@ -33,6 +46,8 @@ const perform = dispatch => ({ deleteComment: commentId => dispatch(doCommentAbandon(commentId)), blockChannel: channelUri => dispatch(doToggleBlockChannel(channelUri)), doToast: options => dispatch(doToast(options)), + pinComment: (commentId, remove) => dispatch(doCommentPin(commentId, remove)), + fetchComments: uri => dispatch(doCommentList(uri)), }); export default connect(select, perform)(Comment); diff --git a/ui/component/comment/view.jsx b/ui/component/comment/view.jsx index a4023c6c3..74f29d5cf 100644 --- a/ui/component/comment/view.jsx +++ b/ui/component/comment/view.jsx @@ -49,6 +49,10 @@ type Props = { like: number, dislike: number, }, + pinComment: (string, boolean) => Promise<any>, + fetchComments: string => void, + commentIdentityChannel: any, + contentChannel: any, }; const LENGTH_TO_COLLAPSE = 300; @@ -78,7 +82,11 @@ function Comment(props: Props) { isTopLevel, threadDepth, isPinned, + pinComment, + fetchComments, othersReacts, + commentIdentityChannel, + contentChannel, } = props; const { push, @@ -139,6 +147,10 @@ function Comment(props: Props) { setCommentValue(!SIMPLE_SITE && advancedEditor ? event : event.target.value); } + function handlePinComment(commentId, remove) { + pinComment(commentId, remove).then(() => fetchComments(uri)); + } + function handleSubmit() { updateComment(commentId, editedMessage); setEditing(false); @@ -204,7 +216,7 @@ function Comment(props: Props) { {isPinned && ( <span className="comment__pin"> - <Icon icon={ICONS.PIN} /> + <Icon icon={ICONS.PIN} size={14} /> {channelOwnerOfContent ? __('Pinned by @%channel%', { channel: channelOwnerOfContent }) : __('Pinned by creator')} @@ -223,18 +235,34 @@ function Comment(props: Props) { <MenuList className="menu__list--comments"> {commentIsMine ? ( <> - <MenuItem className="comment__menu-option" onSelect={() => setEditing(true)}> + <MenuItem className="comment__menu-option menu__link" onSelect={() => setEditing(true)}> + <Icon aria-hidden icon={ICONS.EDIT} /> {__('Edit')} </MenuItem> - <MenuItem className="comment__menu-option" onSelect={() => deleteComment(commentId)}> + <MenuItem className="comment__menu-option menu__link" onSelect={() => deleteComment(commentId)}> + <Icon aria-hidden icon={ICONS.DELETE} /> {__('Delete')} </MenuItem> </> ) : ( - <MenuItem className="comment__menu-option" onSelect={() => blockChannel(authorUri)}> + <MenuItem className="comment__menu-option menu__link" onSelect={() => blockChannel(authorUri)}> + <Icon aria-hidden icon={ICONS.NO} /> {__('Block Channel')} </MenuItem> )} + {commentIdentityChannel === contentChannel && ( + <MenuItem + className="comment__menu-option menu__link" + onSelect={ + isPinned ? () => handlePinComment(commentId, true) : () => handlePinComment(commentId, false) + } + > + <span className={'button__content'}> + <Icon aria-hidden icon={ICONS.PIN} className={'icon'} /> + {isPinned ? __('Unpin') : __('Pin')} + </span> + </MenuItem> + )} </MenuList> </Menu> </div> diff --git a/ui/component/commentCreate/index.js b/ui/component/commentCreate/index.js index 7dae8990a..343b843a3 100644 --- a/ui/component/commentCreate/index.js +++ b/ui/component/commentCreate/index.js @@ -1,8 +1,8 @@ import { connect } from 'react-redux'; import { makeSelectClaimForUri, selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux'; -import { selectIsPostingComment } from 'redux/selectors/comments'; +import { selectIsPostingComment, selectCommentChannel } from 'redux/selectors/comments'; import { doOpenModal } from 'redux/actions/app'; -import { doCommentCreate } from 'redux/actions/comments'; +import { doCommentCreate, doSetCommentChannel } from 'redux/actions/comments'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { CommentCreate } from './view'; @@ -12,12 +12,14 @@ const select = (state, props) => ({ channels: selectMyChannelClaims(state), isFetchingChannels: selectFetchingMyChannels(state), isPostingComment: selectIsPostingComment(state), + activeChannel: selectCommentChannel(state), }); const perform = (dispatch, ownProps) => ({ createComment: (comment, claimId, channel, parentId) => dispatch(doCommentCreate(comment, claimId, channel, parentId, ownProps.uri)), openModal: (modal, props) => dispatch(doOpenModal(modal, props)), + setCommentChannel: name => dispatch(doSetCommentChannel(name)), }); export default connect(select, perform)(CommentCreate); diff --git a/ui/component/commentCreate/view.jsx b/ui/component/commentCreate/view.jsx index 94754239e..3ace15b47 100644 --- a/ui/component/commentCreate/view.jsx +++ b/ui/component/commentCreate/view.jsx @@ -24,6 +24,8 @@ type Props = { parentId: string, isReply: boolean, isPostingComment: boolean, + activeChannel: string, + setCommentChannel: string => void, }; export function CommentCreate(props: Props) { @@ -38,30 +40,32 @@ export function CommentCreate(props: Props) { isReply, parentId, isPostingComment, + activeChannel, + setCommentChannel, } = props; const buttonref: ElementRef<any> = React.useRef(); const { push } = useHistory(); const { claim_id: claimId } = claim; const [commentValue, setCommentValue] = React.useState(''); - const [channel, setChannel] = usePersistedState('comment-channel', ''); + // const [activeChannel, setCommentChannel] = usePersistedState('comment-channel', ''); const [charCount, setCharCount] = useState(commentValue.length); const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); const hasChannels = channels && channels.length; - const disabled = isPostingComment || channel === CHANNEL_NEW || !commentValue.length; + const disabled = isPostingComment || activeChannel === CHANNEL_NEW || !commentValue.length; const topChannel = channels && channels.reduce((top, channel) => { const topClaimCount = (top && top.meta && top.meta.claims_in_channel) || 0; - const currentClaimCount = (channel && channel.meta && channel.meta.claims_in_channel) || 0; + const currentClaimCount = (activeChannel && channel.meta && channel.meta.claims_in_channel) || 0; return topClaimCount >= currentClaimCount ? top : channel; }); useEffect(() => { // set default channel - if ((channel === '' || channel === 'anonymous') && topChannel) { - setChannel(topChannel.name); + if ((activeChannel === '' || activeChannel === 'anonymous') && topChannel) { + setCommentChannel(topChannel.name); } - }, [channel, topChannel, setChannel]); + }, [activeChannel, topChannel, setCommentChannel]); function handleCommentChange(event) { let commentValue; @@ -91,8 +95,8 @@ export function CommentCreate(props: Props) { } function handleSubmit() { - if (channel !== CHANNEL_NEW && commentValue.length) { - createComment(commentValue, claimId, channel, parentId).then(res => { + if (activeChannel !== CHANNEL_NEW && commentValue.length) { + createComment(commentValue, claimId, activeChannel, parentId).then(res => { if (res && res.signature) { setCommentValue(''); @@ -135,13 +139,13 @@ export function CommentCreate(props: Props) { })} > <FormField - disabled={channel === CHANNEL_NEW} + disabled={activeChannel === CHANNEL_NEW} type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'} name={isReply ? 'content_reply' : 'content_description'} label={ <span className="comment-new__label-wrapper"> <div className="comment-new__label">{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}</div> - <ChannelSelection channel={channel} hideAnon tiny hideNew onChannelChange={setChannel} /> + <ChannelSelection channel={activeChannel} hideAnon tiny hideNew onChannelChange={setCommentChannel} /> </span> } quickActionLabel={ diff --git a/ui/component/commentReactions/index.js b/ui/component/commentReactions/index.js index 9dc76dafe..b37b13caa 100644 --- a/ui/component/commentReactions/index.js +++ b/ui/component/commentReactions/index.js @@ -1,11 +1,16 @@ import { connect } from 'react-redux'; import Comment from './view'; -import { makeSelectMyReactionsForComment, makeSelectOthersReactionsForComment } from 'redux/selectors/comments'; +import { + makeSelectMyReactionsForComment, + makeSelectOthersReactionsForComment, + selectCommentChannel, +} from 'redux/selectors/comments'; import { doCommentReact } from 'redux/actions/comments'; const select = (state, props) => ({ myReacts: makeSelectMyReactionsForComment(props.commentId)(state), othersReacts: makeSelectOthersReactionsForComment(props.commentId)(state), + activeChannel: selectCommentChannel(state), }); const perform = dispatch => ({ diff --git a/ui/component/commentsList/index.js b/ui/component/commentsList/index.js index 29494bd3e..0b2199e3c 100644 --- a/ui/component/commentsList/index.js +++ b/ui/component/commentsList/index.js @@ -5,6 +5,7 @@ import { selectIsFetchingComments, makeSelectTotalCommentsCountForUri, selectOthersReactsById, + selectCommentChannel, } from 'redux/selectors/comments'; import { doCommentList, doCommentReactList } from 'redux/actions/comments'; import CommentsList from './view'; @@ -19,6 +20,7 @@ const select = (state, props) => ({ commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, fetchingChannels: selectFetchingMyChannels(state), reactionsById: selectOthersReactsById(state), + activeChannel: selectCommentChannel(state), }); const perform = dispatch => ({ diff --git a/ui/component/commentsList/view.jsx b/ui/component/commentsList/view.jsx index b5cc4c99b..7b3e31fbb 100644 --- a/ui/component/commentsList/view.jsx +++ b/ui/component/commentsList/view.jsx @@ -25,6 +25,7 @@ type Props = { totalComments: number, fetchingChannels: boolean, reactionsById: { [string]: { [REACTION_TYPES.LIKE | REACTION_TYPES.DISLIKE]: number } }, + activeChannel: string, }; function CommentList(props: Props) { @@ -40,11 +41,14 @@ function CommentList(props: Props) { totalComments, fetchingChannels, reactionsById, + activeChannel, } = props; const commentRef = React.useRef(); const spinnerRef = React.useRef(); - const [sort, setSort] = usePersistedState('comment-sort', SORT_COMMENTS_BEST); - const [activeChannel] = usePersistedState('comment-channel', ''); + const [sort, setSort] = usePersistedState( + 'comment-sort', + ENABLE_COMMENT_REACTIONS ? SORT_COMMENTS_BEST : SORT_COMMENTS_NEW + ); const [start] = React.useState(0); const [end, setEnd] = React.useState(9); // Display comments immediately if not fetching reactions @@ -138,9 +142,8 @@ function CommentList(props: Props) { } // Default to newest first for apps that don't have comment reactions - const sortedComments = ENABLE_COMMENT_REACTIONS - ? sortComments({ comments, reactionsById, sort, isMyComment }) - : comments; + const sortedComments = sortComments({ comments, reactionsById, sort, isMyComment }); + const displayedComments = readyToDisplayComments ? prepareComments(sortedComments, linkedComment).slice(start, end) : []; diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 6e192f866..83ec00eb2 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -271,6 +271,10 @@ export const COMMENT_REACTION_LIST_FAILED = 'COMMENT_REACTION_LIST_FAILED'; export const COMMENT_REACT_STARTED = 'COMMENT_REACT_STARTED'; export const COMMENT_REACT_COMPLETED = 'COMMENT_REACT_COMPLETED'; export const COMMENT_REACT_FAILED = 'COMMENT_REACT_FAILED'; +export const COMMENT_PIN_STARTED = 'COMMENT_PIN_STARTED'; +export const COMMENT_PIN_COMPLETED = 'COMMENT_PIN_COMPLETED'; +export const COMMENT_PIN_FAILED = 'COMMENT_PIN_FAILED'; +export const COMMENT_SET_CHANNEL = 'COMMENT_SET_CHANNEL'; // Blocked channels export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL'; diff --git a/ui/redux/actions/comments.js b/ui/redux/actions/comments.js index 9d3551837..5b8f29688 100644 --- a/ui/redux/actions/comments.js +++ b/ui/redux/actions/comments.js @@ -8,6 +8,7 @@ import { makeSelectMyReactionsForComment, makeSelectOthersReactionsForComment, selectPendingCommentReacts, + selectCommentChannel, } from 'redux/selectors/comments'; export function doCommentList(uri: string, page: number = 1, pageSize: number = 99999) { @@ -19,7 +20,7 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number = dispatch({ type: ACTIONS.COMMENT_LIST_STARTED, }); - Lbry.comment_list({ + return Lbry.comment_list({ claim_id: claimId, page, page_size: pageSize, @@ -36,6 +37,7 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number = uri: uri, }, }); + return result; }) .catch(error => { dispatch({ @@ -46,10 +48,19 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number = }; } +export function doSetCommentChannel(channelName: string) { + return (dispatch: Dispatch) => { + dispatch({ + type: ACTIONS.COMMENT_SET_CHANNEL, + data: channelName, + }); + }; +} + export function doCommentReactList(uri: string | null, commentId?: string) { return (dispatch: Dispatch, getState: GetState) => { const state = getState(); - const channel = localStorage.getItem('comment-channel'); + const channel = selectCommentChannel(state); const commentIds = uri ? makeSelectCommentIdsForUri(uri)(state) : [commentId]; const myChannels = selectMyChannelClaims(state); @@ -90,7 +101,7 @@ export function doCommentReactList(uri: string | null, commentId?: string) { export function doCommentReact(commentId: string, type: string) { return (dispatch: Dispatch, getState: GetState) => { const state = getState(); - const channel = localStorage.getItem('comment-channel'); + const channel = selectCommentChannel(state); const pendingReacts = selectPendingCommentReacts(state); const myChannels = selectMyChannelClaims(state); const exclusiveTypes = { @@ -273,6 +284,56 @@ export function doCommentHide(comment_id: string) { }; } +export function doCommentPin(commentId: string, remove: boolean) { + return (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + // const channel = localStorage.getItem('comment-channel'); + const channel = selectCommentChannel(state); + const myChannels = selectMyChannelClaims(state); + const claimForChannelName = myChannels && myChannels.find(chan => chan.name === channel); + const channelId = claimForChannelName && claimForChannelName.claim_id; + + dispatch({ + type: ACTIONS.COMMENT_PIN_STARTED, + }); + if (!channelId || !channel || !commentId) { + return dispatch({ + type: ACTIONS.COMMENT_PIN_FAILED, + data: { message: 'missing params - unable to pin' }, + }); + } + const params: { comment_id: string, channel_name: string, channel_id: string, remove?: boolean } = { + comment_id: commentId, + channel_name: channel, + channel_id: channelId, + }; + + if (remove) { + params['remove'] = true; + } + + return Lbry.comment_pin(params) + .then((result: CommentPinResponse) => { + dispatch({ + type: ACTIONS.COMMENT_PIN_COMPLETED, + data: result, + }); + }) + .catch(error => { + dispatch({ + type: ACTIONS.COMMENT_PIN_FAILED, + data: error, + }); + dispatch( + doToast({ + message: 'Unable to pin this comment, please try again later.', + isError: true, + }) + ); + }); + }; +} + export function doCommentAbandon(comment_id: string) { return (dispatch: Dispatch) => { dispatch({ diff --git a/ui/redux/reducers/comments.js b/ui/redux/reducers/comments.js index 2f89b8e95..7073d2434 100644 --- a/ui/redux/reducers/comments.js +++ b/ui/redux/reducers/comments.js @@ -16,6 +16,7 @@ const defaultState: CommentsState = { typesReacting: [], myReactsByCommentId: {}, othersReactsByCommentId: {}, + commentChannel: '', }; export default handleActions( @@ -30,6 +31,11 @@ export default handleActions( isCommenting: false, }), + [ACTIONS.COMMENT_SET_CHANNEL]: (state: CommentsState, action: any) => ({ + ...state, + commentChannel: action.data, + }), + [ACTIONS.COMMENT_CREATE_COMPLETED]: (state: CommentsState, action: any): CommentsState => { const { comment, claimId, uri }: { comment: Comment, claimId: string, uri: string } = action.data; const commentById = Object.assign({}, state.commentById); diff --git a/ui/redux/selectors/comments.js b/ui/redux/selectors/comments.js index a0220105e..800a9a699 100644 --- a/ui/redux/selectors/comments.js +++ b/ui/redux/selectors/comments.js @@ -16,6 +16,8 @@ export const selectIsPostingComment = createSelector(selectState, state => state export const selectIsFetchingReacts = createSelector(selectState, state => state.isFetchingReacts); +export const selectCommentChannel = createSelector(selectState, state => state.commentChannel); + export const selectOthersReactsById = createSelector(selectState, state => state.othersReactsByCommentId); export const selectCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => { diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss index 57144b94a..10142b169 100644 --- a/ui/scss/component/_comments.scss +++ b/ui/scss/component/_comments.scss @@ -170,6 +170,10 @@ $thumbnailWidthSmall: 0rem; .comment__pin { margin-left: var(--spacing-s); + + .icon { + padding-top: 1px; + } } .comment__message { diff --git a/ui/store.js b/ui/store.js index 0151b5510..44fe11a35 100644 --- a/ui/store.js +++ b/ui/store.js @@ -60,12 +60,14 @@ const appFilter = createFilter('app', [ ]); // We only need to persist the receiveAddress for the wallet const walletFilter = createFilter('wallet', ['receiveAddress']); +const commentsFilter = createFilter('comments', ['commentChannel']); const searchFilter = createFilter('search', ['options']); const tagsFilter = createFilter('tags', ['followedTags']); const subscriptionsFilter = createFilter('subscriptions', ['subscriptions']); const blockedFilter = createFilter('blocked', ['blockedChannels']); const settingsFilter = createBlacklistFilter('settings', ['loadedLanguages', 'language']); const whiteListedReducers = [ + 'comments', 'fileInfo', 'publish', 'wallet', @@ -79,6 +81,7 @@ const whiteListedReducers = [ ]; const transforms = [ + commentsFilter, fileInfoFilter, walletFilter, blockedFilter, diff --git a/yarn.lock b/yarn.lock index 110bb32dc..e7b724c99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7391,9 +7391,9 @@ lazy-val@^1.0.4: yargs "^13.2.2" zstd-codec "^0.1.1" -lbry-redux@lbryio/lbry-redux#ba5d6b84bec6bdb2f0a1a6b23e695212c65f650e: +lbry-redux@lbryio/lbry-redux#a08fc63fe2ee46383ae7e4beb11efe72522a1dd9: version "0.0.1" - resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/ba5d6b84bec6bdb2f0a1a6b23e695212c65f650e" + resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/a08fc63fe2ee46383ae7e4beb11efe72522a1dd9" dependencies: proxy-polyfill "0.1.6" reselect "^3.0.0"