diff --git a/flow-typed/Comment.js b/flow-typed/Comment.js new file mode 100644 index 000000000..c9d32b835 --- /dev/null +++ b/flow-typed/Comment.js @@ -0,0 +1,25 @@ +declare type Comment = { + comment: string, // comment body + comment_id: string, // sha256 digest + claim_id: string, // id linking to the claim this comment + timestamp: number, // integer representing unix-time + is_hidden: boolean, // claim owner may enable/disable this + channel_id?: string, // claimId of channel signing this comment + channel_name?: string, // name of channel claim + channel_url?: string, // full lbry url to signing channel + signature?: string, // signature of comment by originating channel + signing_ts?: string, // timestamp used when signing this comment + is_channel_signature_valid?: boolean, // whether or not the signature could be validated + parent_id?: number, // comment_id of comment this is in reply to +}; + +// todo: relate individual comments to their commentId +declare type CommentsState = { + commentsByUri: { [string]: string }, + byId: { [string]: Array }, + repliesByParentId: { [string]: Array }, // ParentCommentID -> list of reply comments + topLevelCommentsById: { [string]: Array }, // ClaimID -> list of top level comments + commentById: { [string]: Comment }, + isLoading: boolean, + myComments: ?Set, +}; diff --git a/ui/component/comment/index.js b/ui/component/comment/index.js index 2f2c786ba..a78075db5 100644 --- a/ui/component/comment/index.js +++ b/ui/component/comment/index.js @@ -10,6 +10,8 @@ import { doCommentAbandon, doCommentUpdate } from 'redux/actions/comments'; import { doToggleBlockChannel } from 'redux/actions/blocked'; import { selectChannelIsBlocked } from 'redux/selectors/blocked'; import Comment from './view'; +import { selectUserVerifiedEmail } from 'redux/selectors/user'; +import { selectIsFetchingComments } from 'redux/selectors/comments'; const select = (state, props) => ({ pending: props.authorUri && makeSelectClaimIsPending(props.authorUri)(state), @@ -17,6 +19,8 @@ const select = (state, props) => ({ 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), }); const perform = dispatch => ({ diff --git a/ui/component/comment/view.jsx b/ui/component/comment/view.jsx index c0fdd2663..dcd6cb700 100644 --- a/ui/component/comment/view.jsx +++ b/ui/component/comment/view.jsx @@ -6,13 +6,12 @@ import React, { useEffect, useState } from 'react'; import { isEmpty } from 'util/object'; import DateTime from 'component/dateTime'; import Button from 'component/button'; -import Expandable from 'component/expandable'; +// import Expandable from 'component/expandable'; import MarkdownPreview from 'component/common/markdown-preview'; import ChannelThumbnail from 'component/channelThumbnail'; import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; import Icon from 'component/common/icon'; import { FormField, Form } from 'component/common/form'; -import CommentCreate from 'component/commentCreate'; import classnames from 'classnames'; import usePersistedState from 'effects/use-persisted-state'; @@ -34,6 +33,7 @@ type Props = { updateComment: (string, string) => void, deleteComment: string => void, blockChannel: string => void, + linkedComment?: any, }; const LENGTH_TO_COLLAPSE = 300; @@ -57,6 +57,7 @@ function Comment(props: Props) { updateComment, deleteComment, blockChannel, + linkedComment, } = props; const [isEditing, setEditing] = useState(false); @@ -65,11 +66,7 @@ function Comment(props: Props) { // used for controlling the visibility of the menu icon const [mouseIsHovering, setMouseHover] = useState(false); - - // used for controlling visibility of reply comment component - const [isReplying, setReplying] = useState(false); - - const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false); + const [advancedEditor] = usePersistedState('comment-editor-mode', false); // to debounce subsequent requests const shouldFetch = @@ -108,12 +105,15 @@ function Comment(props: Props) { function handleSubmit() { updateComment(commentId, editedMessage); setEditing(false); - setReplying(false); } return (
  • setMouseHover(true)} onMouseOut={() => setMouseHover(false)} > @@ -133,9 +133,16 @@ function Comment(props: Props) { label={author} /> )} - + {/* // link here */} +
  • ); diff --git a/ui/component/commentCreate/view.jsx b/ui/component/commentCreate/view.jsx index 9ae26e1bb..f59697204 100644 --- a/ui/component/commentCreate/view.jsx +++ b/ui/component/commentCreate/view.jsx @@ -21,6 +21,7 @@ type Props = { parentId?: string, onDoneReplying?: () => void, onCancelReplying?: () => void, + isNested: boolean, }; export function CommentCreate(props: Props) { @@ -33,6 +34,7 @@ export function CommentCreate(props: Props) { parentId, onDoneReplying, onCancelReplying, + isNested, } = props; const { claim_id: claimId } = claim; const isReply = !!parentId; @@ -53,9 +55,9 @@ export function CommentCreate(props: Props) { useEffect(() => { // set default channel if ((channel === '' || channel === 'anonymous') && topChannel) { - handleChannelChange(topChannel.name); + setChannel(topChannel.name); } - }, [channel, topChannel]); + }, [channel, topChannel, setChannel]); function handleCommentChange(event) { let commentValue; @@ -68,10 +70,6 @@ export function CommentCreate(props: Props) { setCommentValue(commentValue); } - function handleChannelChange(channel) { - setChannel(channel); - } - function handleCommentAck() { setCommentAck(true); } @@ -107,17 +105,27 @@ export function CommentCreate(props: Props) { } return ( -
    - {!isReply && } + +
    {isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}
    + + + } quickActionLabel={ !SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')) } - quickActionHandler={!SIMPLE_SITE && (isReply ? undefined : toggleEditorMode)} + quickActionHandler={!SIMPLE_SITE && toggleEditorMode} onFocus={onTextareaFocus} placeholder={__('Say something about this...')} value={commentValue} @@ -126,7 +134,7 @@ export function CommentCreate(props: Props) { autoFocus={isReply} textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT} /> -
    +
    + )} + + } + /> ); } diff --git a/ui/component/commentsReplies/index.js b/ui/component/commentsReplies/index.js new file mode 100644 index 000000000..b2fa173e9 --- /dev/null +++ b/ui/component/commentsReplies/index.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { makeSelectClaimIsMine, selectMyChannelClaims } from 'lbry-redux'; +import { makeSelectRepliesForParentId } from 'redux/selectors/comments'; + +import CommentsReplies from './view'; +import { selectUserVerifiedEmail } from 'redux/selectors/user'; + +const select = (state, props) => ({ + myChannels: selectMyChannelClaims(state), + comments: makeSelectRepliesForParentId(props.parentId)(state), + claimIsMine: makeSelectClaimIsMine(props.uri)(state), + commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, +}); + +export default connect(select, null)(CommentsReplies); diff --git a/ui/component/commentsReplies/view.jsx b/ui/component/commentsReplies/view.jsx new file mode 100644 index 000000000..f32699bc2 --- /dev/null +++ b/ui/component/commentsReplies/view.jsx @@ -0,0 +1,155 @@ +// @flow +import React from 'react'; +import Comment from 'component/comment'; +import Button from 'component/button'; +import * as ICONS from 'constants/icons'; +import CommentCreate from 'component/commentCreate'; + +type Props = { + comments: Array, + uri: string, + claimIsMine: boolean, + myChannels: ?Array, + linkedComment?: Comment, + parentId: string, + commentingEnabled: boolean, +}; + +function CommentsReplies(props: Props) { + const { uri, comments, claimIsMine, myChannels, linkedComment, parentId, commentingEnabled } = props; + const [isReplying, setReplying] = React.useState(false); + const [isExpanded, setExpanded] = React.useState(false); + const [start, setStart] = React.useState(0); + const [end, setEnd] = React.useState(9); + const sortedComments = comments ? [...comments].reverse() : []; + const numberOfComments = comments ? comments.length : 0; + + const showMore = () => { + if (start > 0) { + setStart(0); + } else { + setEnd(numberOfComments); + } + }; + + const linkedCommentId = linkedComment ? linkedComment.comment_id : ''; + + const commentsIndexOfLInked = comments && sortedComments.findIndex(e => e.comment_id === linkedCommentId); + + // todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine + const isMyComment = (channelId: string) => { + if (myChannels != null && channelId != null) { + for (let i = 0; i < myChannels.length; i++) { + if (myChannels[i].claim_id === channelId) { + return true; + } + } + } + return false; + }; + + const handleCommentDone = () => { + if (!isExpanded) { + setExpanded(true); + setStart(numberOfComments || 0); + } + setEnd(numberOfComments + 1); + setReplying(false); + }; + + React.useEffect(() => { + if ( + setStart && + setEnd && + setExpanded && + linkedCommentId && + Number.isInteger(commentsIndexOfLInked) && + commentsIndexOfLInked > -1 + ) { + setStart(commentsIndexOfLInked); + setEnd(commentsIndexOfLInked + 1); + setExpanded(true); + } + }, [setStart, setEnd, setExpanded, linkedCommentId, commentsIndexOfLInked]); + + const displayedComments = sortedComments.slice(start, end); + + return ( +
  • +
    +
    + {comments && displayedComments && isExpanded && ( +
    +
    +
    + {!isReplying && ( +
    + )} + {isExpanded && comments && (end < numberOfComments || start > 0) && ( +
    +
    + )} + + {isReplying ? ( + handleCommentDone()} + onCancelReplying={() => setReplying(false)} + /> + ) : ( + '' + )} +
  • + ); +} + +export default CommentsReplies; diff --git a/ui/component/common/form-components/form-field.jsx b/ui/component/common/form-components/form-field.jsx index 5eaaa1b58..913614cc6 100644 --- a/ui/component/common/form-components/form-field.jsx +++ b/ui/component/common/form-components/form-field.jsx @@ -131,6 +131,17 @@ export class FormField extends React.PureComponent { ); + } else if (type === 'select-tiny') { + input = ( + + {(label || errorMessage) && ( + + )} + + + ); } else if (type === 'markdown') { const handleEvents = { contextmenu: openEditorMenu, diff --git a/ui/component/common/icon-custom.jsx b/ui/component/common/icon-custom.jsx index e158b4e0f..a59d9746f 100644 --- a/ui/component/common/icon-custom.jsx +++ b/ui/component/common/icon-custom.jsx @@ -721,4 +721,9 @@ export const icons = { ), + [ICONS.REPLY]: buildIcon( + + + + ), }; diff --git a/ui/component/common/wait-until-on-page.jsx b/ui/component/common/wait-until-on-page.jsx index d66bfb125..e58e6d7e2 100644 --- a/ui/component/common/wait-until-on-page.jsx +++ b/ui/component/common/wait-until-on-page.jsx @@ -7,6 +7,7 @@ const DEBOUNCE_SCROLL_HANDLER_MS = 300; type Props = { children: any, lastUpdateDate?: any, + skipWait?: boolean, }; export default function WaitUntilOnPage(props: Props) { @@ -45,5 +46,5 @@ export default function WaitUntilOnPage(props: Props) { } }, [ref, setShouldRender, shouldRender]); - return
    {shouldRender && props.children}
    ; + return
    {(props.skipWait || shouldRender) && props.children}
    ; } diff --git a/ui/component/selectChannel/view.jsx b/ui/component/selectChannel/view.jsx index b608eda72..8da6a2de5 100644 --- a/ui/component/selectChannel/view.jsx +++ b/ui/component/selectChannel/view.jsx @@ -17,6 +17,7 @@ type Props = { label?: string, injected?: Array, emailVerified: boolean, + tiny: boolean, }; type State = { @@ -91,7 +92,7 @@ class ChannelSelection extends React.PureComponent { render() { const channel = this.state.addingChannel ? CHANNEL_NEW : this.props.channel; - const { fetchingChannels, channels = [], hideAnon, hideNew, label, injected = [] } = this.props; + const { fetchingChannels, channels = [], hideAnon, hideNew, label, injected = [], tiny } = this.props; const { addingChannel } = this.state; return ( @@ -99,8 +100,9 @@ class ChannelSelection extends React.PureComponent { diff --git a/ui/constants/icons.js b/ui/constants/icons.js index aa5fc1c7a..a7cfc6a63 100644 --- a/ui/constants/icons.js +++ b/ui/constants/icons.js @@ -113,3 +113,4 @@ export const OPEN_LOG_FOLDER = 'Folder'; export const LBRY_STATUS = 'BarChart'; export const NOTIFICATION = 'Bell'; export const LAYOUT = 'Layout'; +export const REPLY = 'Reply'; diff --git a/ui/page/file/index.js b/ui/page/file/index.js index 7d9eb2243..985895199 100644 --- a/ui/page/file/index.js +++ b/ui/page/file/index.js @@ -1,6 +1,7 @@ import { connect } from 'react-redux'; import { doRemoveUnreadSubscription } from 'redux/actions/subscriptions'; import { doSetContentHistoryItem } from 'redux/actions/content'; +import { withRouter } from 'react-router'; import { doFetchFileInfo, makeSelectFileInfoForUri, @@ -13,17 +14,25 @@ import { selectShowMatureContent } from 'redux/selectors/settings'; import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions'; import { makeSelectFileRenderModeForUri } from 'redux/selectors/content'; import FilePage from './view'; +import { makeSelectCommentForCommentId } from 'redux/selectors/comments'; -const select = (state, props) => ({ - costInfo: makeSelectCostInfoForUri(props.uri)(state), - metadata: makeSelectMetadataForUri(props.uri)(state), - obscureNsfw: !selectShowMatureContent(state), - isMature: makeSelectClaimIsNsfw(props.uri)(state), - fileInfo: makeSelectFileInfoForUri(props.uri)(state), - isSubscribed: makeSelectIsSubscribed(props.uri)(state), - channelUri: makeSelectChannelForClaimUri(props.uri, true)(state), - renderMode: makeSelectFileRenderModeForUri(props.uri)(state), -}); +const select = (state, props) => { + const { search } = props.location; + const urlParams = new URLSearchParams(search); + const linkedCommentId = urlParams.get('lc'); + + return { + linkedComment: makeSelectCommentForCommentId(linkedCommentId)(state), + costInfo: makeSelectCostInfoForUri(props.uri)(state), + metadata: makeSelectMetadataForUri(props.uri)(state), + obscureNsfw: !selectShowMatureContent(state), + isMature: makeSelectClaimIsNsfw(props.uri)(state), + fileInfo: makeSelectFileInfoForUri(props.uri)(state), + isSubscribed: makeSelectIsSubscribed(props.uri)(state), + channelUri: makeSelectChannelForClaimUri(props.uri, true)(state), + renderMode: makeSelectFileRenderModeForUri(props.uri)(state), + }; +}; const perform = dispatch => ({ fetchFileInfo: uri => dispatch(doFetchFileInfo(uri)), @@ -32,4 +41,4 @@ const perform = dispatch => ({ markSubscriptionRead: (channel, uri) => dispatch(doRemoveUnreadSubscription(channel, uri)), }); -export default connect(select, perform)(FilePage); +export default withRouter(connect(select, perform)(FilePage)); diff --git a/ui/page/file/view.jsx b/ui/page/file/view.jsx index afcef4a03..54619c093 100644 --- a/ui/page/file/view.jsx +++ b/ui/page/file/view.jsx @@ -7,14 +7,12 @@ import FileTitle from 'component/fileTitle'; import FileRenderInitiator from 'component/fileRenderInitiator'; import FileRenderInline from 'component/fileRenderInline'; import FileRenderDownload from 'component/fileRenderDownload'; -import Card from 'component/common/card'; import FileDetails from 'component/fileDetails'; import FileValues from 'component/fileValues'; import FileDescription from 'component/fileDescription'; -import WaitUntilOnPage from 'component/common/wait-until-on-page'; +// import WaitUntilOnPage from 'component/common/wait-until-on-page'; import RecommendedContent from 'component/recommendedContent'; import CommentsList from 'component/commentsList'; -import CommentCreate from 'component/commentCreate'; export const FILE_WRAPPER_CLASS = 'file-page__video-container'; @@ -31,6 +29,7 @@ type Props = { markSubscriptionRead: (string, string) => void, obscureNsfw: boolean, isMature: boolean, + linkedComment: any, }; class FilePage extends React.Component { @@ -136,7 +135,7 @@ class FilePage extends React.Component { lastReset: ?any; render() { - const { uri, renderMode, costInfo, obscureNsfw, isMature } = this.props; + const { uri, renderMode, costInfo, obscureNsfw, isMature, linkedComment } = this.props; if (obscureNsfw && isMature) { return this.renderBlockedPage(); @@ -149,17 +148,9 @@ class FilePage extends React.Component { - - - - - -
    - } - /> + {/* */} + + {/* */} diff --git a/ui/redux/reducers/comments.js b/ui/redux/reducers/comments.js index c7633fa39..5812aead0 100644 --- a/ui/redux/reducers/comments.js +++ b/ui/redux/reducers/comments.js @@ -5,8 +5,11 @@ import { handleActions } from 'util/redux-utils'; const defaultState: CommentsState = { commentById: {}, // commentId -> Comment byId: {}, // ClaimID -> list of comments + repliesByParentId: {}, // ParentCommentID -> list of reply comments + topLevelCommentsById: {}, // ClaimID -> list of top level comments commentsByUri: {}, // URI -> claimId isLoading: false, + isCommenting: false, myComments: undefined, }; @@ -14,18 +17,20 @@ export default handleActions( { [ACTIONS.COMMENT_CREATE_STARTED]: (state: CommentsState, action: any): CommentsState => ({ ...state, - isLoading: true, + isCommenting: true, }), [ACTIONS.COMMENT_CREATE_FAILED]: (state: CommentsState, action: any) => ({ ...state, - isLoading: false, + isCommenting: false, }), [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); const byId = Object.assign({}, state.byId); + const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]} + const repliesByParentId = Object.assign({}, state.repliesByParentId); // {ParentCommentID -> [commentIds...] } list of reply comments const comments = byId[claimId] || []; const newCommentIds = comments.slice(); const commentsByUri = Object.assign({}, state.commentsByUri); @@ -37,16 +42,30 @@ export default handleActions( newCommentIds.unshift(comment.comment_id); byId[claimId] = newCommentIds; - if (!commentsByUri[uri]) { - commentsByUri[uri] = claimId; + if (comment['parent_id']) { + if (!repliesByParentId[comment.parent_id]) { + repliesByParentId[comment.parent_id] = [comment.comment_id]; + } else { + repliesByParentId[comment.parent_id].unshift(comment.comment_id); + } + } else { + if (!topLevelCommentsById[claimId]) { + commentsByUri[uri] = claimId; + topLevelCommentsById[claimId] = [comment.comment_id]; + } else { + topLevelCommentsById[claimId].unshift(comment.comment_id); + } } return { ...state, + topLevelCommentsById, + repliesByParentId, commentById, byId, commentsByUri, isLoading: false, + isCommenting: false, }; }, @@ -57,8 +76,11 @@ export default handleActions( const commentById = Object.assign({}, state.commentById); const byId = Object.assign({}, state.byId); + const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById); // was byId {ClaimId -> [commentIds...]} const commentsByUri = Object.assign({}, state.commentsByUri); + const tempRepliesByParent = {}; + const topLevelComments = []; if (comments) { // we use an Array to preserve order of listing // in reality this doesn't matter and we can just @@ -67,15 +89,32 @@ export default handleActions( // map the comment_ids to the new comments for (let i = 0; i < comments.length; i++) { + const comment = comments[i]; + if (comment['parent_id']) { + if (!tempRepliesByParent[comment.parent_id]) { + tempRepliesByParent[comment.parent_id] = [comment.comment_id]; + } else { + tempRepliesByParent[comment.parent_id].push(comment.comment_id); + } + } else { + commentById[comment.comment_id] = comment; + topLevelComments.push(comment.comment_id); + } commentIds[i] = comments[i].comment_id; commentById[commentIds[i]] = comments[i]; } + topLevelCommentsById[claimId] = topLevelComments; byId[claimId] = commentIds; commentsByUri[uri] = claimId; } + + const repliesByParentId = Object.assign({}, state.repliesByParentId, tempRepliesByParent); // {ParentCommentID -> [commentIds...] } list of reply comments + return { ...state, + topLevelCommentsById, + repliesByParentId, byId, commentById, commentsByUri, @@ -116,12 +155,12 @@ export default handleActions( // do nothing [ACTIONS.COMMENT_ABANDON_FAILED]: (state: CommentsState, action: any) => ({ ...state, - isLoading: false, + isCommenting: false, }), // do nothing [ACTIONS.COMMENT_UPDATE_STARTED]: (state: CommentsState, action: any) => ({ ...state, - isLoading: true, + isCommenting: true, }), // replace existing comment with comment returned here under its comment_id [ACTIONS.COMMENT_UPDATE_COMPLETED]: (state: CommentsState, action: any) => { @@ -132,13 +171,13 @@ export default handleActions( return { ...state, commentById, - isLoading: false, + isCommenting: false, }; }, // nothing can be done here [ACTIONS.COMMENT_UPDATE_FAILED]: (state: CommentsState, action: any) => ({ ...state, - isLoading: false, + isCmmenting: false, }), // nothing can really be done here [ACTIONS.COMMENT_HIDE_STARTED]: (state: CommentsState, action: any) => ({ diff --git a/ui/redux/selectors/blocked.js b/ui/redux/selectors/blocked.js index cd980782e..f9f1f45bb 100644 --- a/ui/redux/selectors/blocked.js +++ b/ui/redux/selectors/blocked.js @@ -9,6 +9,16 @@ export const selectBlockedChannels = createSelector(selectState, (state: Blockli export const selectBlockedChannelsCount = createSelector(selectBlockedChannels, (state: Array) => state.length); +export const selectBlockedChannelsObj = createSelector(selectState, (state: BlocklistState) => { + return state.blockedChannels.reduce((acc: any, val: any) => { + const outpoint = `${val.txid}:${String(val.nout)}`; + return { + ...acc, + [outpoint]: 1, + }; + }, {}); +}); + export const selectChannelIsBlocked = (uri: string) => createSelector(selectBlockedChannels, (state: Array) => { return state.includes(uri); diff --git a/ui/redux/selectors/comments.js b/ui/redux/selectors/comments.js index 7b5f37520..81b9268f9 100644 --- a/ui/redux/selectors/comments.js +++ b/ui/redux/selectors/comments.js @@ -3,7 +3,7 @@ import * as SETTINGS from 'constants/settings'; import { createSelector } from 'reselect'; import { selectBlockedChannels } from 'redux/selectors/blocked'; import { makeSelectClientSetting } from 'redux/selectors/settings'; -import { selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc'; +import { selectBlacklistedOutpointMap, selectFilteredOutpointMap } from 'lbryinc'; import { selectClaimsById, isClaimNsfw, selectMyActiveClaims } from 'lbry-redux'; const selectState = state => state.comments || {}; @@ -12,10 +12,29 @@ export const selectCommentsById = createSelector(selectState, state => state.com export const selectIsFetchingComments = createSelector(selectState, state => state.isLoading); +export const selectIsPostingComment = createSelector(selectState, state => state.isCommenting); + export const selectCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => { const byClaimId = state.byId || {}; const comments = {}; + // replace every comment_id in the list with the actual comment object + Object.keys(byClaimId).forEach((claimId: string) => { + const commentIds = byClaimId[claimId]; + + comments[claimId] = Array(commentIds === null ? 0 : commentIds.length); + for (let i = 0; i < commentIds.length; i++) { + comments[claimId][i] = byId[commentIds[i]]; + } + }); + + return comments; +}); + +export const selectTopLevelCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => { + const byClaimId = state.topLevelCommentsById || {}; + const comments = {}; + // replace every comment_id in the list with the actual comment object Object.keys(byClaimId).forEach(claimId => { const commentIds = byClaimId[claimId]; @@ -29,6 +48,26 @@ export const selectCommentsByClaimId = createSelector(selectState, selectComment return comments; }); +export const makeSelectCommentForCommentId = (commentId: string) => + createSelector(selectCommentsById, comments => comments[commentId]); + +export const selectRepliesByParentId = createSelector(selectState, selectCommentsById, (state, byId) => { + const byParentId = state.repliesByParentId || {}; + const comments = {}; + + // replace every comment_id in the list with the actual comment object + Object.keys(byParentId).forEach(id => { + const commentIds = byParentId[id]; + + comments[id] = Array(commentIds === null ? 0 : commentIds.length); + for (let i = 0; i < commentIds.length; i++) { + comments[id][i] = byId[commentIds[i]]; + } + }); + + return comments; +}); + // previously this used a mapping from claimId -> Array /* export const selectCommentsById = createSelector( selectState, @@ -56,39 +95,112 @@ export const makeSelectCommentsForUri = (uri: string) => selectClaimsById, selectMyActiveClaims, selectBlockedChannels, - selectBlackListedOutpoints, - selectFilteredOutpoints, + selectBlacklistedOutpointMap, + selectFilteredOutpointMap, makeSelectClientSetting(SETTINGS.SHOW_MATURE), - ( - byClaimId, - byUri, - claimsById, - myClaims, - blockedChannels, - blacklistedOutpoints, - filteredOutpoints, - showMatureContent - ) => { + (byClaimId, byUri, claimsById, myClaims, blockedChannels, blacklistedMap, filteredMap, showMatureContent) => { const claimId = byUri[uri]; const comments = byClaimId && byClaimId[claimId]; - const blacklistedMap = blacklistedOutpoints - ? blacklistedOutpoints.reduce((acc, val) => { - const outpoint = `${val.txid}:${val.nout}`; - return { - ...acc, - [outpoint]: 1, - }; - }, {}) - : {}; - const filteredMap = filteredOutpoints - ? filteredOutpoints.reduce((acc, val) => { - const outpoint = `${val.txid}:${val.nout}`; - return { - ...acc, - [outpoint]: 1, - }; - }, {}) - : {}; + + return comments + ? comments.filter(comment => { + const channelClaim = claimsById[comment.channel_id]; + + // Return comment if `channelClaim` doesn't exist so the component knows to resolve the author + if (channelClaim) { + if (myClaims && myClaims.size > 0) { + const claimIsMine = channelClaim.is_my_output || myClaims.has(channelClaim.claim_id); + if (claimIsMine) { + return true; + } + } + + const outpoint = `${channelClaim.txid}:${channelClaim.nout}`; + if (blacklistedMap[outpoint] || filteredMap[outpoint]) { + return false; + } + + if (!showMatureContent) { + const claimIsMature = isClaimNsfw(channelClaim); + if (claimIsMature) { + return false; + } + } + } + + return !blockedChannels.includes(comment.channel_url); + }) + : []; + } + ); + +export const makeSelectTopLevelCommentsForUri = (uri: string) => + createSelector( + selectTopLevelCommentsByClaimId, + selectCommentsByUri, + selectClaimsById, + selectMyActiveClaims, + selectBlockedChannels, + selectBlacklistedOutpointMap, + selectFilteredOutpointMap, + makeSelectClientSetting(SETTINGS.SHOW_MATURE), + (byClaimId, byUri, claimsById, myClaims, blockedChannels, blacklistedMap, filteredMap, showMatureContent) => { + const claimId = byUri[uri]; + const comments = byClaimId && byClaimId[claimId]; + + return comments + ? comments.filter(comment => { + const channelClaim = claimsById[comment.channel_id]; + + // Return comment if `channelClaim` doesn't exist so the component knows to resolve the author + if (channelClaim) { + if (myClaims && myClaims.size > 0) { + const claimIsMine = channelClaim.is_my_output || myClaims.has(channelClaim.claim_id); + if (claimIsMine) { + return true; + } + } + + const outpoint = `${channelClaim.txid}:${channelClaim.nout}`; + if (blacklistedMap[outpoint] || filteredMap[outpoint]) { + return false; + } + + if (!showMatureContent) { + const claimIsMature = isClaimNsfw(channelClaim); + if (claimIsMature) { + return false; + } + } + } + + return !blockedChannels.includes(comment.channel_url); + }) + : []; + } + ); + +export const makeSelectRepliesForParentId = (id: string) => + createSelector( + selectState, // no selectRepliesByParentId + selectCommentsById, + selectClaimsById, + selectMyActiveClaims, + selectBlockedChannels, + selectBlacklistedOutpointMap, + selectFilteredOutpointMap, + makeSelectClientSetting(SETTINGS.SHOW_MATURE), + (state, commentsById, claimsById, myClaims, blockedChannels, blacklistedMap, filteredMap, showMatureContent) => { + // const claimId = byUri[uri]; // just parentId (id) + const replyIdsByParentId = state.repliesByParentId; + const replyIdsForParent = replyIdsByParentId[id] || []; + if (!replyIdsForParent.length) return null; + + const comments = []; + replyIdsForParent.forEach(cid => { + comments.push(commentsById[cid]); + }); + // const comments = byParentId && byParentId[id]; return comments ? comments.filter(comment => { diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss index c8c2352b9..6b2451766 100644 --- a/ui/scss/component/_comments.scss +++ b/ui/scss/component/_comments.scss @@ -1,75 +1,145 @@ +$thumbnailWidth: 3rem; +$thumbnailWidthSmall: 2rem; + .comments { - padding-top: var(--spacing-l); + list-style-type: none; + font-size: var(--font-small); +} + +.comments--replies { + margin-left: var(--spacing-m); + flex: 1; +} + +.comment__create { + padding-bottom: var(--spacing-l); + font-size: var(--font-small); +} + +.comment__create--reply { + margin-top: var(--spacing-m); + margin-left: calc(#{$thumbnailWidth} + var(--spacing-m)); + position: relative; } .comment { display: flex; flex-direction: row; - font-size: var(--font-body); + font-size: var(--font-small); margin: 0; - &:not(:last-of-type) { - border-bottom: 1px solid var(--color-border); - padding: var(--spacing-m) 0; - } - &:last-of-type { - padding-top: var(--spacing-m); + &:not(:first-child) { + margin-top: var(--spacing-l); } .channel-thumbnail { - @include handleChannelGif(3rem); + @include handleChannelGif($thumbnailWidthSmall); + margin-right: 0; + + @media (min-width: $breakpoint-small) { + @include handleChannelGif($thumbnailWidth); + } } } -.comment__create--reply { - margin-top: var(--spacing-s); +.comment__replies-container { + margin: 0; +} + +.comment__replies { + display: flex; + margin-top: var(--spacing-m); + margin-left: calc(#{$thumbnailWidthSmall} + var(--spacing-xs)); + + @media (min-width: $breakpoint-small) { + margin-left: calc(#{$thumbnailWidth} + var(--spacing-m)); + } } .comment__reply { - border-left: 5px solid var(--color-primary-alt); - margin-left: var(--spacing-m); + margin: 0; - .comment__author-thumbnail { - margin-left: var(--spacing-m); + &:not(:first-child) { + margin-top: var(--spacing-m); } } -.comment__reply-button { - margin-top: var(--spacing-s); +.comment__threadline { + @extend .button--alt; + height: auto; + align-self: stretch; + padding: 2px; + border-radius: 3px; + background-color: var(--color-comment-threadline); + + &:hover { + box-shadow: 0 0 0 1px var(--color-comment-threadline-hover); + background-color: var(--color-comment-threadline-hover); + border-color: var(--color-comment-threadline-hover); + } +} + +.comment-new__label-wrapper { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: baseline; + flex-wrap: wrap; + width: 100%; + + @media (min-width: $breakpoint-small) { + fieldset-section { + max-width: 10rem; + } + } +} + +.comment-new__label { + white-space: nowrap; + margin-right: var(--spacing-xs); +} + +.comment__highlighted { + background: var(--color-comment-highlighted); + box-shadow: 0 0 0 var(--spacing-xs) var(--color-comment-highlighted); + border-radius: 4px; } .comment__body_container { - padding-right: var(--spacing-s); flex: 1; - width: 80%; + margin-left: var(--spacing-xs); + + @media (min-width: $breakpoint-small) { + margin-left: var(--spacing-m); + } } .comment__meta { display: flex; justify-content: space-between; text-overflow: ellipsis; - margin-bottom: var(--spacing-s); } .comment__meta-information { justify-content: flex-start; display: flex; + height: 100%; } .comment__message { - white-space: pre-line; word-break: break-word; - margin-top: var(--spacing-s); } .comment__author { text-overflow: ellipsis; padding-right: var(--spacing-xs); + height: 100%; } .comment__time { opacity: 0.3; white-space: nowrap; + height: 100%; } .comment__menu { @@ -109,3 +179,49 @@ border-radius: var(--card-radius); padding: var(--spacing-s); } + +.comment__actions { + display: flex; + margin-top: var(--spacing-s); + margin-left: calc(#{$thumbnailWidthSmall} + var(--spacing-xs)); + + > *:not(:last-child) { + margin-right: var(--spacing-xs); + } + + .icon { + margin-right: var(--spacing-xxs); + } + + .button__label { + margin-left: 0; + } + + @media (min-width: $breakpoint-small) { + margin-left: calc(#{$thumbnailWidth} + var(--spacing-m)); + } +} + +.comment__action { + @extend .button--uri-indicator; + height: auto; + font-size: var(--font-xsmall); +} + +.comment__action--nested { + @extend .comment__action; +} + +.comment__action--nested, +.comment__create--nested-reply { + margin-top: var(--spacing-s); + margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px); + + @media (min-width: $breakpoint-small) { + margin-left: calc((#{$thumbnailWidth} + var(--spacing-m)) * 2 + var(--spacing-m) + 4px); + } +} + +.comment__more-below { + margin-top: var(--spacing-l); +} diff --git a/ui/scss/component/_form-field.scss b/ui/scss/component/_form-field.scss index 9f6fa708b..c078d5977 100644 --- a/ui/scss/component/_form-field.scss +++ b/ui/scss/component/_form-field.scss @@ -413,7 +413,9 @@ fieldset-group { } .form-field__two-column { - column-count: 2; + @media (min-width: $breakpoint-small) { + column-count: 2; + } } .form-field__quick-action { @@ -431,4 +433,11 @@ fieldset-section { margin-top: var(--spacing-s); // Extra specificity needed here since _section.scss is applied after this file } } + + .select--slim { + select { + max-height: 1.5rem !important; + padding: 0 var(--spacing-xs); + } + } } diff --git a/ui/scss/component/_markdown-preview.scss b/ui/scss/component/_markdown-preview.scss index c2e475b87..98a83eecc 100644 --- a/ui/scss/component/_markdown-preview.scss +++ b/ui/scss/component/_markdown-preview.scss @@ -45,8 +45,6 @@ font-size: 1em; } - p, - blockquote, dl, ul, ol, diff --git a/ui/scss/init/_color.scss b/ui/scss/init/_color.scss index 888e437fb..6ed47a17a 100644 --- a/ui/scss/init/_color.scss +++ b/ui/scss/init/_color.scss @@ -48,7 +48,7 @@ --color-input-color: #111111; --color-input-label: var(--color-gray-5); --color-input-placeholder: #212529; - --color-input-bg: var(--color-gray-1); + --color-input-bg: #f4f4f4; --color-input-bg-copyable: #434b53; --color-input-border: var(--color-border); --color-input-border-active: var(--color-secondary); diff --git a/ui/scss/init/_gui.scss b/ui/scss/init/_gui.scss index 426932ea4..9608c0567 100644 --- a/ui/scss/init/_gui.scss +++ b/ui/scss/init/_gui.scss @@ -44,12 +44,6 @@ h6 { font-size: 1rem; } -p { - & + p { - margin-top: var(--spacing-s); - } -} - ul, ol { li { diff --git a/ui/scss/themes/dark.scss b/ui/scss/themes/dark.scss index 7af8a3d7c..9616f20e5 100644 --- a/ui/scss/themes/dark.scss +++ b/ui/scss/themes/dark.scss @@ -53,6 +53,7 @@ --color-purchased: #ffd580; --color-purchased-alt: var(--color-purchased); --color-purchased-text: var(--color-gray-5); + --color-comment-highlighted: #484734; // Text --color-text: #eeeeee; diff --git a/ui/scss/themes/light.scss b/ui/scss/themes/light.scss index aeecffd00..7e30673a2 100644 --- a/ui/scss/themes/light.scss +++ b/ui/scss/themes/light.scss @@ -24,6 +24,10 @@ --color-purchased: var(--color-cost); --color-purchased-alt: #ffebc2; --color-purchased-text: var(--color-gray-5); + --color-comment-highlighted: #fff2d9; + --color-comment-threadline: var(--color-gray-2); + --color-comment-threadline-hover: var(--color-gray-4); + --color-comment-threadline-border: var(--color-gray-2); // Icons --color-follow-bg: #ffd4da;