import { h, Component, Fragment } from 'preact'; import { useEffect, useRef } from 'preact/hooks'; import { useSelector } from 'react-redux'; import b from 'bem-react-helper'; import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl'; import clsx from 'clsx'; import 'styles/global.css'; import type { StoreState } from 'store'; import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, THEMES, IS_MOBILE } from 'common/constants'; import { maxShownComments, url } from 'common/settings'; import { StaticStore } from 'common/static-store'; import { setUser, fetchUser, blockUser, unblockUser, fetchBlockedUsers, hideUser, unhideUser, signout, } from 'store/user/actions'; import { fetchComments, addComment, updateComment, unsetCommentMode } from 'store/comments/actions'; import { setCommentsReadOnlyState } from 'store/post-info/actions'; import { setTheme } from 'store/theme/actions'; // TODO: make this button as default for all cases and replace current `components/Button` import { Button } from 'components/auth/components/button'; import { Preloader } from 'components/preloader'; import { Settings } from 'components/settings'; import { AuthPanel } from 'components/auth-panel'; import { SortPicker } from 'components/sort-picker'; import { CommentForm } from 'components/comment-form'; import { Thread } from 'components/thread'; import { ConnectedComment as Comment } from 'components/comment/connected-comment'; import { uploadImage, getPreview } from 'common/api'; import { isUserAnonymous } from 'utils/isUserAnonymous'; import { bindActions } from 'utils/actionBinder'; import { postMessageToParent, parseMessage } from 'utils/post-message'; import { useActions } from 'hooks/useAction'; import { setCollapse } from 'store/thread/actions'; import styles from './root.module.css'; const mapStateToProps = (state: StoreState) => ({ sort: state.comments.sort, isCommentsLoading: state.comments.isFetching, user: state.user, childToParentComments: Object.entries(state.comments.childComments).reduce( (accumulator: Record, [key, children]) => { children.forEach((child) => (accumulator[child] = key)); return accumulator; }, {} ), collapsedThreads: state.collapsedThreads, topComments: state.comments.topComments, pinnedComments: state.comments.pinnedComments.map((id) => state.comments.allComments[id]).filter((c) => !c.hidden), theme: state.theme, info: state.info, hiddenUsers: state.hiddenUsers, blockedUsers: state.bannedUsers, getPreview, uploadImage, }); const boundActions = bindActions({ fetchComments, setUser, fetchUser, fetchBlockedUsers, setTheme, setCommentsReadOnlyState, blockUser, unblockUser, hideUser, unhideUser, addComment, updateComment, setCollapse, unsetCommentMode, signout, }); type Props = ReturnType & typeof boundActions & { intl: IntlShape }; interface State { isUserLoading: boolean; isSettingsVisible: boolean; commentsShown: number; wasSomeoneUnblocked: boolean; } const messages = defineMessages({ pinnedComments: { id: 'root.pinned-comments', defaultMessage: 'Pinned comments', }, }); const getCollapsedParent = (hash: string, childToParentComments: Record) => { let id = hash.replace(`#${COMMENT_NODE_CLASSNAME_PREFIX}`, ''); while (childToParentComments[id]) { id = childToParentComments[id]; } return id; }; /** main component fr main comments widget */ export class Root extends Component { state = { isUserLoading: true, commentsShown: maxShownComments, wasSomeoneUnblocked: false, isSettingsVisible: false, }; componentDidMount() { const userloading = this.props.fetchUser().finally(() => this.setState({ isUserLoading: false })); Promise.all([userloading, this.props.fetchComments()]).finally(() => { setTimeout(this.checkUrlHash); window.addEventListener('hashchange', this.checkUrlHash); postMessageToParent({ height: document.body.offsetHeight }); }); window.addEventListener('message', this.onMessage); } checkUrlHash = (e: Event & { newURL: string }) => { const hash = e ? `#${e.newURL.split('#')[1]}` : window.location.hash; if (hash.indexOf(`#${COMMENT_NODE_CLASSNAME_PREFIX}`) === 0) { if (e) e.preventDefault(); if (!document.querySelector(hash)) { const id = getCollapsedParent(hash, this.props.childToParentComments); const indexHash = this.props.topComments.findIndex((item) => item === id); const multiplierCollapsed = Math.ceil(indexHash / MAX_SHOWN_ROOT_COMMENTS); this.setState( { commentsShown: this.state.commentsShown + MAX_SHOWN_ROOT_COMMENTS * multiplierCollapsed, }, () => setTimeout(() => this.toMessage(hash), 500) ); } else { this.toMessage(hash); } } }; toMessage = (hash: string) => { const comment = document.querySelector(hash); if (comment) { postMessageToParent({ scrollTo: comment.getBoundingClientRect().top }); comment.classList.add('comment_highlighting'); setTimeout(() => { comment.classList.remove('comment_highlighting'); }, 5e3); } }; onMessage = (event: MessageEvent) => { const data = parseMessage(event); if (data.signout === true) { this.props.signout(false); } if (!data.theme || !THEMES.includes(data.theme)) { return; } this.props.setTheme(data.theme); }; onBlockedUsersShow = async () => { if (this.props.user && this.props.user.admin) { await this.props.fetchBlockedUsers(); } this.setState({ isSettingsVisible: true }); }; onBlockedUsersHide = async () => { // if someone was unblocked let's reload comments if (this.state.wasSomeoneUnblocked) { this.props.fetchComments(); } this.setState({ wasSomeoneUnblocked: false, isSettingsVisible: false, }); }; onUnblockSomeone = () => { this.setState({ wasSomeoneUnblocked: true }); }; showMore = () => { this.setState({ commentsShown: this.state.commentsShown + MAX_SHOWN_ROOT_COMMENTS, }); }; render(props: Props, { isUserLoading, commentsShown, isSettingsVisible }: State) { if (isUserLoading) { return ; } const isCommentsDisabled = props.info.read_only!; const imageUploadHandler = isUserAnonymous(this.props.user) ? undefined : this.props.uploadImage; return (
{isSettingsVisible ? ( ) : ( <> {!isCommentsDisabled && ( this.props.addComment(text, title)} getPreview={this.props.getPreview} uploadImage={imageUploadHandler} simpleView={StaticStore.config.simple_view} /> )} {this.props.pinnedComments.length > 0 && (
{this.props.pinnedComments.map((comment) => ( ))}
)}
{!!this.props.topComments.length && !props.isCommentsLoading && (
{(IS_MOBILE && commentsShown < this.props.topComments.length ? this.props.topComments.slice(0, commentsShown) : this.props.topComments ).map((id) => ( ))} {commentsShown < this.props.topComments.length && IS_MOBILE && ( )}
)} {props.isCommentsLoading && (
)} )}
); } } const CopyrightLink = (title: string) => ( {title} ); /** Root component connected to redux */ export function ConnectedRoot() { const intl = useIntl(); const props = useSelector(mapStateToProps); const actions = useActions(boundActions); const rootRef = useRef(null); useEffect(() => { // TODO: throttle updates const observer = new MutationObserver(() => { postMessageToParent({ height: document.body.offsetHeight }); }); observer.observe(rootRef.current, { attributes: true, childList: true, subtree: true }); return () => observer.disconnect(); }, []); return (

); }