diff --git a/static/app-strings.json b/static/app-strings.json index 36a82cd1a..5baa9217f 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -209,6 +209,9 @@ "Failed to copy.": "Failed to copy.", "The publisher has chosen to charge %lbc% to view this content. Your balance is currently too low to view it. Check out %reward_link% for free %lbc% or send more %lbc% to your wallet. You can also %buy_link% more %lbc%.": "The publisher has chosen to charge %lbc% to view this content. Your balance is currently too low to view it. Check out %reward_link% for free %lbc% or send more %lbc% to your wallet. You can also %buy_link% more %lbc%.", "Connecting...": "Connecting...", + "Your comments": "Your comments", + "View your past comments.": "View your past comments.", + "Content or channel was deleted.": "Content or channel was deleted.", "Comments": "Comments", "Comment": "Comment", "Comment --[button to submit something]--": "Comment", @@ -1460,6 +1463,7 @@ "Staked LBRY Credits": "Staked LBRY Credits", "1 comment": "1 comment", "%total_comments% comments": "%total_comments% comments", + "No comments": "No comments", "Upvote": "Upvote", "Downvote": "Downvote", "You loved this": "You loved this", diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx index 03db2b538..ad5234265 100644 --- a/ui/component/router/view.jsx +++ b/ui/component/router/view.jsx @@ -59,6 +59,7 @@ const ListBlockedPage = lazyImport(() => import('page/listBlocked' /* webpackChu const ListsPage = lazyImport(() => import('page/lists' /* webpackChunkName: "secondary" */)); const LiveStreamSetupPage = lazyImport(() => import('page/livestreamSetup' /* webpackChunkName: "secondary" */)); const LivestreamCurrentPage = lazyImport(() => import('page/livestreamCurrent' /* webpackChunkName: "secondary" */)); +const OwnComments = lazyImport(() => import('page/ownComments' /* webpackChunkName: "ownComments" */)); const PasswordResetPage = lazyImport(() => import('page/passwordReset' /* webpackChunkName: "secondary" */)); const PasswordSetPage = lazyImport(() => import('page/passwordSet' /* webpackChunkName: "secondary" */)); const PublishPage = lazyImport(() => import('page/publish' /* webpackChunkName: "secondary" */)); @@ -329,6 +330,7 @@ function AppRouter(props: Props) { + diff --git a/ui/component/settingAccount/index.js b/ui/component/settingAccount/index.js index 83d77af76..0644ceaf6 100644 --- a/ui/component/settingAccount/index.js +++ b/ui/component/settingAccount/index.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { doWalletStatus, selectWalletIsEncrypted } from 'lbry-redux'; +import { doWalletStatus, selectMyChannelClaims, selectWalletIsEncrypted } from 'lbry-redux'; import { selectUser, selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectLanguage } from 'redux/selectors/settings'; @@ -9,6 +9,7 @@ const select = (state) => ({ isAuthenticated: selectUserVerifiedEmail(state), walletEncrypted: selectWalletIsEncrypted(state), user: selectUser(state), + myChannels: selectMyChannelClaims(state), language: selectLanguage(state), }); diff --git a/ui/component/settingAccount/view.jsx b/ui/component/settingAccount/view.jsx index f39262379..812e3818c 100644 --- a/ui/component/settingAccount/view.jsx +++ b/ui/component/settingAccount/view.jsx @@ -15,12 +15,13 @@ type Props = { isAuthenticated: boolean, walletEncrypted: boolean, user: User, + myChannels: ?Array, // --- perform --- doWalletStatus: () => void, }; export default function SettingAccount(props: Props) { - const { isAuthenticated, walletEncrypted, user, doWalletStatus } = props; + const { isAuthenticated, walletEncrypted, user, myChannels, doWalletStatus } = props; const [storedPassword, setStoredPassword] = React.useState(false); // Determine if password is stored. @@ -92,6 +93,17 @@ export default function SettingAccount(props: Props) { )} {/* @endif */} + + {myChannels && ( + + + + )} > } /> diff --git a/ui/constants/pageTitles.js b/ui/constants/pageTitles.js index 66afc32e6..fd330974b 100644 --- a/ui/constants/pageTitles.js +++ b/ui/constants/pageTitles.js @@ -52,6 +52,7 @@ export const PAGE_TITLE = { [PAGES.SETTINGS_STRIPE_ACCOUNT]: 'Bank Accounts', [PAGES.SETTINGS_STRIPE_CARD]: 'Payment Methods', [PAGES.SETTINGS_UPDATE_PWD]: 'Update password', + [PAGES.SETTINGS_OWN_COMMENTS]: 'Your comments', [PAGES.SWAP]: 'Swap Credits', [PAGES.TAGS_FOLLOWING]: 'Tags', [PAGES.TAGS_FOLLOWING_MANAGE]: 'Manage tags', diff --git a/ui/constants/pages.js b/ui/constants/pages.js index db1853800..495c1bf93 100644 --- a/ui/constants/pages.js +++ b/ui/constants/pages.js @@ -46,6 +46,7 @@ exports.SETTINGS_NOTIFICATIONS = 'settings/notifications'; exports.SETTINGS_BLOCKED_MUTED = 'settings/block_and_mute'; exports.SETTINGS_CREATOR = 'settings/creator'; exports.SETTINGS_UPDATE_PWD = 'settings/update_password'; +exports.SETTINGS_OWN_COMMENTS = 'settings/ownComments'; exports.SHOW = 'show'; exports.ACCOUNT = 'account'; exports.SEARCH = 'search'; diff --git a/ui/page/ownComments/index.js b/ui/page/ownComments/index.js new file mode 100644 index 000000000..b2b18ac4d --- /dev/null +++ b/ui/page/ownComments/index.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; +import { doCommentListOwn, doCommentReset } from 'redux/actions/comments'; +import { selectActiveChannelClaim } from 'redux/selectors/app'; +import { + selectIsFetchingComments, + makeSelectCommentsForUri, + makeSelectTotalCommentsCountForUri, + makeSelectTopLevelTotalPagesForUri, +} from 'redux/selectors/comments'; +import { selectClaimsById } from 'lbry-redux'; + +import OwnComments from './view'; + +const select = (state) => { + const activeChannelClaim = selectActiveChannelClaim(state); + const uri = activeChannelClaim && activeChannelClaim.canonical_url; + + return { + activeChannelClaim, + allComments: makeSelectCommentsForUri(uri)(state), + totalComments: makeSelectTotalCommentsCountForUri(uri)(state), + topLevelTotalPages: makeSelectTopLevelTotalPagesForUri(uri)(state), + isFetchingComments: selectIsFetchingComments(state), + claimsById: selectClaimsById(state), + }; +}; + +const perform = (dispatch) => ({ + doCommentReset: (a) => dispatch(doCommentReset(a)), + doCommentListOwn: (a, b, c, d) => dispatch(doCommentListOwn(a, b, c, d)), +}); + +export default connect(select, perform)(OwnComments); diff --git a/ui/page/ownComments/view.jsx b/ui/page/ownComments/view.jsx new file mode 100644 index 000000000..d5de78b67 --- /dev/null +++ b/ui/page/ownComments/view.jsx @@ -0,0 +1,210 @@ +// @flow +import React from 'react'; +import Button from 'component/button'; +import ChannelSelector from 'component/channelSelector'; +import ClaimPreview from 'component/claimPreview'; +import Comment from 'component/comment'; +import Card from 'component/common/card'; +import Empty from 'component/common/empty'; +import Page from 'component/page'; +import Spinner from 'component/spinner'; +import { SORT_BY, COMMENT_PAGE_SIZE_TOP_LEVEL } from 'constants/comment'; +import * as ICONS from 'constants/icons'; +import useFetched from 'effects/use-fetched'; +import debounce from 'util/debounce'; + +function scaleToDevicePixelRatio(value) { + const devicePixelRatio = window.devicePixelRatio || 1.0; + if (devicePixelRatio < 1.0) { + return Math.ceil(value / devicePixelRatio); + } + return Math.ceil(value * devicePixelRatio); +} + +type Props = { + activeChannelClaim: ?ChannelClaim, + allComments: Array, + totalComments: number, + topLevelTotalPages: number, + isFetchingComments: boolean, + claimsById: any, + doCommentReset: (claimId: string) => void, + doCommentListOwn: (channelId: string, page: number, pageSize: number, sortBy: number) => void, +}; + +export default function OwnComments(props: Props) { + const { + activeChannelClaim, + allComments, + totalComments, + isFetchingComments, + claimsById, + doCommentReset, + doCommentListOwn, + } = props; + const spinnerRef = React.useRef(); + const [page, setPage] = React.useState(0); + const [activeChannelId, setActiveChannelId] = React.useState(''); + + // Since we are sharing the key for Discussion and MyComments, don't show + // the list until we've gone through the initial reset. + const wasResetAndReady = useFetched(isFetchingComments); + + const totalPages = Math.ceil(totalComments / COMMENT_PAGE_SIZE_TOP_LEVEL); + const moreBelow = page < totalPages; + + function getCommentsElem(comments) { + return comments.map((comment) => { + const contentClaim = claimsById[comment.claim_id]; + const isChannel = contentClaim && contentClaim.value_type === 'channel'; + const isLivestream = Boolean(contentClaim && contentClaim.value_type === 'stream' && !contentClaim.value.source); + + return ( + + + + {contentClaim && ( + null} + /> + )} + {!contentClaim && } + + + + + ); + }); + } + + // Active channel changed + React.useEffect(() => { + if (activeChannelClaim && activeChannelClaim.claim_id !== activeChannelId) { + setActiveChannelId(activeChannelClaim.claim_id); + setPage(0); + } + }, [activeChannelClaim, activeChannelId]); + + // Reset comments + React.useEffect(() => { + if (page === 0 && activeChannelId) { + doCommentReset(activeChannelId); + setPage(1); + } + }, [page, activeChannelId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Fetch own comments + React.useEffect(() => { + if (page !== 0 && activeChannelId) { + doCommentListOwn(activeChannelId, page, COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY.NEWEST); + } + }, [page]); // eslint-disable-line react-hooks/exhaustive-deps + + // Infinite scroll + React.useEffect(() => { + function shouldFetchNextPage(page, topLevelTotalPages, window, document, yPrefetchPx = 1000) { + if (!spinnerRef || !spinnerRef.current) { + return false; + } + + const rect = spinnerRef.current.getBoundingClientRect(); // $FlowFixMe + const windowH = window.innerHeight || document.documentElement.clientHeight; // $FlowFixMe + const windowW = window.innerWidth || document.documentElement.clientWidth; // $FlowFixMe + + const isApproachingViewport = yPrefetchPx !== 0 && rect.top < windowH + scaleToDevicePixelRatio(yPrefetchPx); + + const isInViewport = + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + // $FlowFixMe + rect.top <= windowH && + // $FlowFixMe + rect.left <= windowW; + + return (isInViewport || isApproachingViewport) && page < topLevelTotalPages; + } + + const handleCommentScroll = debounce(() => { + if (shouldFetchNextPage(page, totalPages, window, document)) { + setPage(page + 1); + } + }, 200); + + if (!isFetchingComments && moreBelow && spinnerRef && spinnerRef.current) { + if (shouldFetchNextPage(page, totalPages, window, document, 0)) { + setPage(page + 1); + } else { + window.addEventListener('scroll', handleCommentScroll); + return () => window.removeEventListener('scroll', handleCommentScroll); + } + } + }, [page, spinnerRef, isFetchingComments, moreBelow, totalPages]); + + // ************************************************************************** + // ************************************************************************** + + if (!activeChannelClaim) { + return null; + } + + return ( + + + 0 + ? totalComments === 1 + ? __('1 comment') + : __('%total_comments% comments', { total_comments: totalComments }) + : isFetchingComments + ? '' + : __('No comments') + } + titleActions={ + { + setPage(0); + }} + /> + } + body={ + <> + {wasResetAndReady && {allComments && getCommentsElem(allComments)}} + {(isFetchingComments || moreBelow) && ( + + + + )} + > + } + /> + + ); +} diff --git a/ui/scss/component/_comments.scss b/ui/scss/component/_comments.scss index 1beab514c..2ba53cd2c 100644 --- a/ui/scss/component/_comments.scss +++ b/ui/scss/component/_comments.scss @@ -487,3 +487,60 @@ $thumbnailWidthSmall: 1rem; margin-bottom: -3px; // TODO fix few instances of these (find "-2px") } } + +.comments-own { + .section__actions { + align-items: flex-start; + } + + .comments-own--claim { + min-width: 100%; + max-width: 100%; + + @media (min-width: $breakpoint-medium) { + min-width: 40%; + max-width: 40%; + } + + .media__thumb { + flex-shrink: 0; + overflow: hidden; + $width: 5rem; + @include handleClaimListGifThumbnail($width); + width: $width; + height: calc(#{$width} * (9 / 16)); + margin-right: var(--spacing-s); + } + + .channel-thumbnail { + @include handleChannelGif(calc(5rem * 9 / 16)); + margin-right: var(--spacing-xs); + + @media (min-width: $breakpoint-small) { + @include handleChannelGif(calc(5rem * 9 / 16)); + margin-right: var(--spacing-s); + } + } + } + + .claim-preview__wrapper { + margin: 0 0; + padding: 0; + + @media (min-width: $breakpoint-medium) { + margin: 0 var(--spacing-xs); + } + } + + .comment { + margin-top: var(--spacing-s); + margin-left: 0; + padding-left: var(--spacing-m); + border-left: 4px solid var(--color-border); + + @media (min-width: $breakpoint-medium) { + margin-top: 0; + margin-left: var(--spacing-s); + } + } +}