diff --git a/packages/commonwealth/client/scripts/views/components/ReactionButton/CommentReactionButton.tsx b/packages/commonwealth/client/scripts/views/components/ReactionButton/CommentReactionButton.tsx index f6de4d8dea5..ef0eb481490 100644 --- a/packages/commonwealth/client/scripts/views/components/ReactionButton/CommentReactionButton.tsx +++ b/packages/commonwealth/client/scripts/views/components/ReactionButton/CommentReactionButton.tsx @@ -1,6 +1,5 @@ -import 'components/ReactionButton/CommentReactionButton.scss'; -import { notifyError } from 'controllers/app/notifications'; import React, { useState, useEffect } from 'react'; +import { notifyError } from 'controllers/app/notifications'; import app from 'state'; import type ChainInfo from '../../../models/ChainInfo'; import type Comment from '../../../models/Comment'; @@ -8,45 +7,41 @@ import ReactionCount from '../../../models/ReactionCount'; import { useCreateCommentReactionMutation, useDeleteCommentReactionMutation, - useFetchCommentReactionsQuery + useFetchCommentReactionsQuery, } from '../../../state/api/comments'; import Permissions from '../../../utils/Permissions'; import { LoginModal } from '../../modals/login_modal'; -import { CWIcon } from '../component_kit/cw_icons/cw_icon'; import { Modal } from '../component_kit/cw_modal'; -import { CWTooltip } from '../component_kit/cw_popover/cw_tooltip'; -import { - getClasses, - isWindowMediumSmallInclusive, -} from '../component_kit/helpers'; -import { - getDisplayedReactorsForPopup, - onReactionClick, -} from './helpers'; +import { isWindowMediumSmallInclusive } from '../component_kit/helpers'; +import { getDisplayedReactorsForPopup, onReactionClick } from './helpers'; +import CWUpvoteSmall from 'views/components/component_kit/new_designs/CWUpvoteSmall'; type CommentReactionButtonProps = { comment: Comment; + disabled: boolean; }; export const CommentReactionButton = ({ comment, + disabled, }: CommentReactionButtonProps) => { - const [isLoading, setIsLoading] = useState(false); const [reactors, setReactors] = useState>([]); const [isModalOpen, setIsModalOpen] = useState(false); const [reactionCounts, setReactionCounts] = useState>(); - const { mutateAsync: createCommentReaction } = useCreateCommentReactionMutation({ - commentId: comment.id, - chainId: app.activeChainId() - }) - const { mutateAsync: deleteCommentReaction } = useDeleteCommentReactionMutation({ - commentId: comment.id, - chainId: app.activeChainId() - }) + const { mutateAsync: createCommentReaction } = + useCreateCommentReactionMutation({ + commentId: comment.id, + chainId: app.activeChainId(), + }); + const { mutateAsync: deleteCommentReaction } = + useDeleteCommentReactionMutation({ + commentId: comment.id, + chainId: app.activeChainId(), + }); const { data: reactions } = useFetchCommentReactionsQuery({ chainId: app.activeChainId(), commentId: comment.id, - }) + }); useEffect(() => { const redrawFunction = (comment_id) => { @@ -82,8 +77,6 @@ export const CommentReactionButton = ({ return r.Address.address === activeAddress; }); - setIsLoading(true); - deleteCommentReaction({ canvasHash: foundReaction.canvas_hash, reactionId: foundReaction.id, @@ -92,34 +85,49 @@ export const CommentReactionButton = ({ likes: likes - 1, hasReacted: false, }, - }).then(() => { - setReactors( - reactors.filter(({ Address }) => Address.address !== userAddress) - ); - setReactionCounts(app.comments.reactionCountsStore.getByPost(comment)); - setIsLoading(false); - }).catch(() => { - notifyError('Failed to update reaction count'); - }); + }) + .then(() => { + setReactors( + reactors.filter(({ Address }) => Address.address !== userAddress) + ); + setReactionCounts(app.comments.reactionCountsStore.getByPost(comment)); + }) + .catch(() => { + notifyError('Failed to update reaction count'); + }); }; const like = (chain: ChainInfo, chainId: string, userAddress: string) => { - setIsLoading(true); - createCommentReaction({ address: userAddress, commentId: comment.id, chainId: chainId, - }).then(() => { - setReactors([ - ...reactors, - { Address: { address: userAddress, chain } }, - ]); - setReactionCounts(app.comments.reactionCountsStore.getByPost(comment)); - setIsLoading(false); - }).catch(() => { - notifyError('Failed to save reaction'); }) + .then(() => { + setReactors([ + ...reactors, + { Address: { address: userAddress, chain } }, + ]); + setReactionCounts(app.comments.reactionCountsStore.getByPost(comment)); + }) + .catch(() => { + notifyError('Failed to save reaction'); + }); + }; + + const handleVoteClick = async (e) => { + e.stopPropagation(); + e.preventDefault(); + + if (!app.isLoggedIn() || !app.user.activeAccount) { + setIsModalOpen(true); + } else { + onReactionClick(e, hasReacted, dislike, like); + } + }; + + const handleVoteMouseEnter = async () => { + setReactors(reactions); }; return ( @@ -130,66 +138,16 @@ export const CommentReactionButton = ({ onClose={() => setIsModalOpen(false)} open={isModalOpen} /> - + r.Address.address), + })} + /> ); }; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/cw_component_showcase.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/cw_component_showcase.tsx index ad07dd80778..910f4993b01 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/cw_component_showcase.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/cw_component_showcase.tsx @@ -1024,8 +1024,7 @@ export const ComponentShowcase = () => { /> console.log('Upvote action clicked!!')} + onClick={() => console.log('Upvote action clicked!')} /> { /> console.log('Upvote action clicked!')} disabled /> diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/cw_content_page.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/cw_content_page.tsx index 0d0349c1769..97ed8934f26 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/cw_content_page.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/cw_content_page.tsx @@ -8,8 +8,8 @@ import MinimumProfile from '../../../models/MinimumProfile'; import { Thread } from '../../../models/Thread'; import Topic from '../../../models/Topic'; import { ThreadStage } from '../../../models/types'; -import { AuthorAndPublishInfo as ThreadAuthorAndPublishInfo } from '../../pages/discussions/ThreadCard/AuthorAndPublishInfo'; -import { Options as ThreadOptions } from '../../pages/discussions/ThreadCard/Options'; +import { AuthorAndPublishInfo } from '../../pages/discussions/ThreadCard/AuthorAndPublishInfo'; +import { ThreadOptions } from '../../pages/discussions/ThreadCard/ThreadOptions'; import { CWCard } from './cw_card'; import { CWTab, CWTabBar } from './cw_tabs'; import { CWText } from './cw_text'; @@ -116,7 +116,7 @@ export const CWContentPage = ({ title )}
- { }; }; -export const Popover = (props: PopoverProps) => { - const { anchorEl, content, id, open, placement } = props; - +export const Popover = ({ + anchorEl, + content, + id, + open, + placement, + disablePortal, +}: PopoverProps) => { return ( { - const { content, hasBackground, renderTrigger, placement } = props; - +export const CWTooltip = ({ + content, + hasBackground, + renderTrigger, + placement, + disablePortal, +}: TooltipProps) => { const popoverProps = usePopover(); return ( <> {renderTrigger(popoverProps.handleInteraction)} void; + onClick: (e: React.MouseEvent) => void; + tooltipContent: JSX.Element; +} + +const CWUpvoteSmall = ({ + voteCount, + onMouseEnter, + onClick, + selected, + disabled, + tooltipContent, +}: CWUpvoteSmallProps) => { + const handleClick = (e) => { + if (disabled) { + return; + } + + onClick?.(e); + }; + + return ( +
({ disabled })} + onMouseEnter={onMouseEnter} + onClick={handleClick} + > + {voteCount > 0 && !disabled ? ( + ( +
+ +
+ )} + /> + ) : ( + <> + + + )} +
+ ); +}; + +export default CWUpvoteSmall; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/cw_thread_action.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/cw_thread_action.tsx index ca2fbf37f92..bd50409d864 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/cw_thread_action.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/cw_thread_action.tsx @@ -1,10 +1,11 @@ -import React, { FC, useState } from 'react'; +import React, { FC } from 'react'; import { ArrowBendUpRight, - ArrowFatUp, BellSimple, + BellSimpleSlash, DotsThree, ChatCenteredDots, + ArrowFatUp, } from '@phosphor-icons/react'; import { CWText } from '../cw_text'; @@ -12,40 +13,51 @@ import { getClasses } from '../helpers'; import { ComponentType } from '../types'; import 'components/component_kit/new_designs/cw_thread_action.scss'; +import { CWTooltip } from 'views/components/component_kit/cw_popover/cw_tooltip'; export type ActionType = + | 'upvote' | 'comment' + | 'reply' | 'share' | 'subscribe' - | 'upvote' | 'overflow'; -const commonProps = (disabled: boolean, isHovering: boolean) => { +const commonProps = (disabled: boolean) => { return { className: getClasses({ disabled, - hover: isHovering, }), - size: '20px', + size: '18px', }; }; const renderPhosphorIcon = ( action: ActionType, disabled: boolean, - isHovering: boolean + selected: boolean ) => { switch (action) { + case 'upvote': + return ( + + ); case 'comment': - return ; + case 'reply': + return ; case 'share': - return ; + return ; case 'subscribe': - return ; - case 'upvote': - return ; + return selected ? ( + + ) : ( + + ); case 'overflow': - return ; + return ; default: return null; } @@ -54,53 +66,97 @@ const renderPhosphorIcon = ( type CWThreadActionProps = { disabled?: boolean; action?: ActionType; - count?: number; - onClick: () => void; + onClick?: (e: React.MouseEvent) => void; + label?: string; + selected?: boolean; +}; + +interface TooltipWrapperProps { + disabled: boolean; + text: string; + children: JSX.Element; +} + +// Tooltip should only wrap the ThreadAction when the button is disabled +const TooltipWrapper = ({ children, disabled, text }: TooltipWrapperProps) => { + if (!disabled) { + return <>{children}; + } + + return ( + ( +
+ {children} +
+ )} + /> + ); +}; + +const getTooltipCopy = (action: ActionType) => { + switch (action) { + case 'upvote': + return 'Join community to upvote'; + case 'comment': + return 'Join community to comment'; + case 'reply': + return 'Join community to reply'; + case 'overflow': + return 'Join community to view more actions'; + case 'subscribe': + return 'Join community to subscribe'; + default: + return ''; + } }; export const CWThreadAction: FC = ({ disabled, action, - count, onClick, + label, + selected, }) => { - const [isHovering, setIsHovering] = useState(false); - - const handleOnMouseOver = () => { - if (!disabled) { - setIsHovering(true); + const handleClick = (e) => { + if (disabled) { + return; } + + onClick?.(e); }; - const handleOnMouseLeave = () => setIsHovering(false); + const upvoteSelected = action === 'upvote' && selected; return ( -
- {renderPhosphorIcon(action, disabled, isHovering)} - {action !== 'overflow' && (action || count) && ( - +
+ upvoteSelected, + }, + ComponentType.ThreadAction + )} + > + {renderPhosphorIcon(action, disabled, selected)} + {action !== 'overflow' && action && ( + + {label || action.charAt(0).toUpperCase() + action.slice(1)} + + )} + + ); }; diff --git a/packages/commonwealth/client/scripts/views/components/share_popover.tsx b/packages/commonwealth/client/scripts/views/components/share_popover.tsx index e24b53a481e..d63a74d88de 100644 --- a/packages/commonwealth/client/scripts/views/components/share_popover.tsx +++ b/packages/commonwealth/client/scripts/views/components/share_popover.tsx @@ -1,10 +1,10 @@ import React from 'react'; import app from '../../state'; -import { CWIconButton } from './component_kit/cw_icon_button'; import type { PopoverTriggerProps } from './component_kit/cw_popover/cw_popover'; import { PopoverMenu } from './component_kit/cw_popover/cw_popover_menu'; import { useLocation } from 'react-router-dom'; +import { CWThreadAction } from 'views/components/component_kit/new_designs/cw_thread_action'; type SharePopoverProps = { commentId?: number; @@ -19,8 +19,8 @@ export const SharePopover = ({ const domain = document.location.origin; const { pathname: currentRoute } = useLocation(); - const defaultRenderTrigger = (onclick) => ( - + const defaultRenderTrigger = (onClick) => ( + ); return ( @@ -34,9 +34,8 @@ export const SharePopover = ({ if (commentId) { // If we copy a comment on discussion page - const currentRouteSansCommentParam = currentRoute.split( - '?comment=' - )[0]; + const currentRouteSansCommentParam = + currentRoute.split('?comment=')[0]; urlToCopy = `${domain}${currentRouteSansCommentParam}?comment=${commentId}`; } else if (discussionLink) { const urlParts = currentRoute.split('/'); diff --git a/packages/commonwealth/client/scripts/views/menus/create_content_menu.tsx b/packages/commonwealth/client/scripts/views/menus/create_content_menu.tsx index 37eda060529..24b8806ddcb 100644 --- a/packages/commonwealth/client/scripts/views/menus/create_content_menu.tsx +++ b/packages/commonwealth/client/scripts/views/menus/create_content_menu.tsx @@ -9,6 +9,7 @@ import { CWMobileMenu } from '../components/component_kit/cw_mobile_menu'; import type { PopoverMenuItem } from '../components/component_kit/cw_popover/cw_popover_menu'; import { PopoverMenu } from '../components/component_kit/cw_popover/cw_popover_menu'; import { CWSidebarMenu } from '../components/component_kit/cw_sidebar_menu'; +import useUserActiveAccount from 'hooks/useUserActiveAccount'; const resetSidebarState = () => { sidebarStore.getState().setMenu({ name: 'default', isVisible: false }); @@ -253,12 +254,13 @@ export const CreateContentMenu = () => { export const CreateContentPopover = () => { const navigate = useCommonNavigate(); const { isLoggedIn } = useUserLoggedIn(); + const { activeAccount: hasJoinedCommunity } = useUserActiveAccount(); if ( !isLoggedIn || !app.chain || !app.activeChainId() || - !app.user.activeAccount + !hasJoinedCommunity ) { return; } diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/CommentCard.scss similarity index 62% rename from packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.scss rename to packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/CommentCard.scss index 43341a8b9b3..8e61f931b2d 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.scss +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/CommentCard.scss @@ -1,4 +1,5 @@ @import '../../../../../styles/shared'; +@import '../../../../../styles/components/component_kit/cw_popover/cw_tooltip'; .thread-connectors-container { display: flex; @@ -23,7 +24,6 @@ .Comment { display: flex; - &.highlighted { background-color: $yellow-200; border-radius: $border-radius-corners; @@ -43,7 +43,6 @@ gap: 12px; .comment-text { - div, span, p { @@ -94,63 +93,10 @@ .comment-footer { display: flex; align-items: center; - gap: 8px; - - .comment-option-btn { - outline: 0; - border: 0; - display: flex; - justify-content: center; - align-items: center; - gap: 4px; - cursor: pointer; - padding: 4px 8px; - border-radius: $border-radius-corners-wider; - color: $neutral-600; - - &:hover { - background-color: $neutral-200; - } - - .Text { - color: $neutral-600; - } - } - - .reply-button { - align-items: center; - cursor: pointer; - display: flex; - gap: 4px; - - .Icon, - .menu-buttons-text.Text { - color: $neutral-500; - } - - &:hover { - - .Icon, - .menu-buttons-text.Text { - color: $neutral-600; - } - } + gap: 1px; - &:active { - - .Icon, - .menu-buttons-text.Text { - color: $neutral-800; - } - } - - &.selected { - - .Icon, - .menu-buttons-text.Text { - color: $neutral-800; - } - } + .Tooltip { + @include newTempBlackTooltip; } } } @@ -158,14 +104,14 @@ .CommentActions { .danger { - color: $rorange-600 !important; + color: $rorange-600 !important; .Icon { - color: $rorange-600 !important; + color: $rorange-600 !important; } .Text { - color: $rorange-600 !important; + color: $rorange-600 !important; } } } @@ -182,4 +128,4 @@ justify-content: flex-end; width: 100%; } -} \ No newline at end of file +} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/CommentCard.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/CommentCard.tsx new file mode 100644 index 00000000000..5a239bb9fc0 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/CommentCard.tsx @@ -0,0 +1,173 @@ +import moment from 'moment'; +import type { DeltaStatic } from 'quill'; +import React, { useState } from 'react'; +import app from 'state'; +import type Comment from '../../../../models/Comment'; +import { CWButton } from 'views/components/component_kit/new_designs/cw_button'; +import { PopoverMenu } from 'views/components/component_kit/cw_popover/cw_popover_menu'; +import { CWTag } from 'views/components/component_kit/cw_tag'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { CommentReactionButton } from 'views/components/ReactionButton/CommentReactionButton'; +import { ReactQuillEditor } from '../../../components/react_quill_editor'; +import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; +import { deserializeDelta } from 'views/components/react_quill_editor/utils'; +import { SharePopover } from 'views/components/share_popover'; +import { AuthorAndPublishInfo } from '../ThreadCard/AuthorAndPublishInfo'; +import './CommentCard.scss'; +import { CWThreadAction } from 'views/components/component_kit/new_designs/cw_thread_action'; +import useUserActiveAccount from 'hooks/useUserActiveAccount'; + +type CommentCardProps = { + // Edit + canEdit?: boolean; + onEditStart?: () => any; + onEditConfirm?: (comment: DeltaStatic) => any; + onEditCancel?: (hasContentChanged: boolean) => any; + isEditing?: boolean; + isSavingEdit?: boolean; + editDraft?: string; + // Delete + canDelete?: boolean; + onDelete?: () => any; + // Reply + replyBtnVisible?: boolean; + onReply?: () => any; + // Spam + isSpam?: boolean; + onSpamToggle?: () => any; + canToggleSpam?: boolean; + // actual comment + comment: Comment; +}; + +export const CommentCard = ({ + // edit + editDraft, + canEdit, + onEditStart, + onEditCancel, + onEditConfirm, + isEditing, + isSavingEdit, + // delete + canDelete, + onDelete, + // reply + replyBtnVisible, + onReply, + // spam + isSpam, + onSpamToggle, + canToggleSpam, + // actual comment + comment, +}: CommentCardProps) => { + const commentBody = deserializeDelta(editDraft || comment.text); + const [commentDelta, setCommentDelta] = useState(commentBody); + const { activeAccount: hasJoinedCommunity } = useUserActiveAccount(); + + return ( +
+
+ {comment.deleted ? ( + [deleted] + ) : ( + + )} +
+ {isEditing ? ( +
+ +
+ { + e.preventDefault(); + const hasContentChanged = + JSON.stringify(commentBody) !== JSON.stringify(commentDelta); + onEditCancel(hasContentChanged); + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + await onEditConfirm(commentDelta); + }} + /> +
+
+ ) : ( +
+ {isSpam && } + + + + {!comment.deleted && ( +
+ + + + + {replyBtnVisible && ( + { + e.preventDefault(); + e.stopPropagation(); + await onReply(); + }} + /> + )} + + {(canEdit || canDelete) && ( + ( + + )} + menuItems={[ + canEdit && { + label: 'Edit', + iconLeft: 'write' as const, + onClick: onEditStart, + iconLeftWeight: 'bold' as const, + }, + canToggleSpam && { + onClick: onSpamToggle, + label: !isSpam ? 'Flag as spam' : 'Unflag as spam', + iconLeft: 'flag' as const, + iconLeftWeight: 'bold' as const, + }, + canDelete && { + label: 'Delete', + iconLeft: 'trash' as const, + onClick: onDelete, + className: 'danger', + iconLeftWeight: 'bold' as const, + }, + ].filter(Boolean)} + /> + )} +
+ )} +
+ )} +
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.tsx index 7debd8ff1b2..721c1a33841 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentCard/index.tsx @@ -1,202 +1,3 @@ -import moment from 'moment'; -import type { DeltaStatic } from 'quill'; -import React, { useState } from 'react'; -import app from 'state'; -import type Comment from '../../../../models/Comment'; -import { CWButton } from '../../../components/component_kit/new_designs/cw_button'; -import { CWIcon } from '../../../components/component_kit/cw_icons/cw_icon'; -import { PopoverMenu } from '../../../components/component_kit/cw_popover/cw_popover_menu'; -import { CWTag } from '../../../components/component_kit/cw_tag'; -import { CWText } from '../../../components/component_kit/cw_text'; -import { CommentReactionButton } from '../../../components/ReactionButton/CommentReactionButton'; -import { ReactQuillEditor } from '../../../components/react_quill_editor'; -import { QuillRenderer } from '../../../components/react_quill_editor/quill_renderer'; -import { deserializeDelta } from '../../../components/react_quill_editor/utils'; -import { SharePopover } from '../../../components/share_popover'; -import { AuthorAndPublishInfo } from '../ThreadCard/AuthorAndPublishInfo'; -import './index.scss'; +import { CommentCard } from './CommentCard'; -type CommentCardProps = { - // Edit - canEdit?: boolean; - onEditStart?: () => any; - onEditConfirm?: (comment: DeltaStatic) => any; - onEditCancel?: (hasContentChanged: boolean) => any; - isEditing?: boolean; - isSavingEdit?: boolean; - editDraft?: string; - // Delete - canDelete?: boolean; - onDelete?: () => any; - // Reply - canReply?: boolean; - onReply?: () => any; - // Spam - isSpam?: boolean; - onSpamToggle?: () => any; - canToggleSpam?: boolean; - // actual comment - comment: Comment; -}; - -export const CommentCard = ({ - // edit - editDraft, - canEdit, - onEditStart, - onEditCancel, - onEditConfirm, - isEditing, - isSavingEdit, - // delete - canDelete, - onDelete, - // reply - canReply, - onReply, - // spam - isSpam, - onSpamToggle, - canToggleSpam, - // actual comment - comment, -}: CommentCardProps) => { - const commentBody = deserializeDelta(editDraft || comment.text); - const [commentDelta, setCommentDelta] = useState(commentBody); - - return ( -
-
- {comment.deleted ? ( - [deleted] - ) : ( - - )} -
- {isEditing ? ( -
- -
- { - e.preventDefault(); - const hasContentChanged = - JSON.stringify(commentBody) !== JSON.stringify(commentDelta); - onEditCancel(hasContentChanged); - }} - /> - { - e.preventDefault(); - e.stopPropagation(); - await onEditConfirm(commentDelta); - }} - /> -
-
- ) : ( -
- {isSpam && } - - - - {!comment.deleted && ( -
- - - ( - - )} - /> - - {canReply && ( - - )} - - {(canEdit || canDelete) && ( - ( - - )} - menuItems={[ - canEdit && { - label: 'Edit', - iconLeft: 'write' as const, - onClick: onEditStart, - iconLeftWeight: 'bold' as const, - }, - canToggleSpam && { - onClick: onSpamToggle, - label: !isSpam ? 'Flag as spam' : 'Unflag as spam', - iconLeft: 'flag' as const, - iconLeftWeight: 'bold' as const, - }, - canDelete && { - label: 'Delete', - iconLeft: 'trash' as const, - onClick: onDelete, - className: 'danger', - iconLeftWeight: 'bold' as const, - }, - ].filter(Boolean)} - /> - )} -
- )} -
- )} -
- ); -}; +export { CommentCard }; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.scss similarity index 100% rename from packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/index.scss rename to packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.scss diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx new file mode 100644 index 00000000000..64320a9aa9c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx @@ -0,0 +1,451 @@ +import useUserLoggedIn from 'hooks/useUserLoggedIn'; +import type { DeltaStatic } from 'quill'; +import React, { useEffect, useState } from 'react'; +import app from 'state'; +import { ContentType } from 'types'; +import { openConfirmation } from 'views/modals/confirmation_modal'; +import { notifyError } from '../../../../controllers/app/notifications'; +import type { Comment as CommentType } from '../../../../models/Comment'; +import Thread from '../../../../models/Thread'; +import Permissions from '../../../../utils/Permissions'; +import { CreateComment } from 'views/components/Comments/CreateComment'; +import { CWValidationText } from 'views/components/component_kit/cw_validation_text'; +import { + deserializeDelta, + serializeDelta, +} from 'views/components/react_quill_editor/utils'; +import { CommentCard } from '../CommentCard'; +import { clearEditingLocalStorage } from '../CommentTree/helpers'; +import { jumpHighlightComment } from './helpers'; +import './CommentTree.scss'; + +const MAX_THREAD_LEVEL = 8; + +type CommentsTreeAttrs = { + comments: Array>; + thread: Thread; + setIsGloballyEditing?: (status: boolean) => void; + updatedCommentsCallback: () => void; + includeSpams: boolean; + isReplying: boolean; + setIsReplying: (status: boolean) => void; + parentCommentId: number; + setParentCommentId: (id: number) => void; + canComment: boolean; +}; + +export const CommentTree = ({ + comments, + thread, + setIsGloballyEditing, + updatedCommentsCallback, + includeSpams, + isReplying, + setIsReplying, + parentCommentId, + setParentCommentId, + canComment, +}: CommentsTreeAttrs) => { + const [commentError] = useState(null); + const [highlightedComment, setHighlightedComment] = useState(false); + + const [edits, setEdits] = useState<{ + [commentId: number]: { + isEditing?: boolean; + editDraft?: string; + isSavingEdit?: boolean; + contentDelta?: DeltaStatic; + }; + }>(); + + const { isLoggedIn } = useUserLoggedIn(); + + const isAdminOrMod = + Permissions.isSiteAdmin() || + Permissions.isCommunityAdmin() || + Permissions.isCommunityModerator(); + + const isLocked = !!(thread instanceof Thread && thread.readOnly); + + useEffect(() => { + if (comments?.length > 0 && !highlightedComment) { + setHighlightedComment(true); + + const commentId = window.location.search.startsWith('?comment=') + ? window.location.search.replace('?comment=', '') + : null; + + if (commentId) jumpHighlightComment(Number(commentId)); + } + }, [comments?.length, highlightedComment]); + + // eslint-disable-next-line @typescript-eslint/no-shadow + const handleIsReplying = (isReplying: boolean, id?: number) => { + if (isReplying) { + setParentCommentId(id); + setIsReplying(true); + } else { + setParentCommentId(undefined); + setIsReplying(false); + } + }; + + const isLivingCommentTree = (comment, children) => { + if (!comment.deleted) { + return true; + } else if (!children.length) { + return false; + } else { + let survivingDescendents = false; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + if (!child.deleted) { + survivingDescendents = true; + break; + } + + const grandchildren = app.comments + .getByThread(thread) + .filter((c) => c.parentComment === child.id); + + for (let j = 0; j < grandchildren.length; j++) { + const grandchild = grandchildren[j]; + + if (!grandchild.deleted) { + survivingDescendents = true; + break; + } + } + + if (survivingDescendents) break; + } + + return survivingDescendents; + } + }; + + const handleDeleteComment = (comment: CommentType) => { + openConfirmation({ + title: 'Delete Comment', + description: <>Delete this comment?, + buttons: [ + { + label: 'Delete', + buttonType: 'mini-red', + onClick: async () => { + try { + await app.comments.delete(comment, thread.id); + updatedCommentsCallback(); + } catch (e) { + console.log(e); + notifyError('Failed to delete comment.'); + } + }, + }, + { + label: 'Cancel', + buttonType: 'mini-black', + }, + ], + }); + }; + + const handleEditCancel = ( + comment: CommentType, + hasContentChanged: boolean + ) => { + if (hasContentChanged) { + openConfirmation({ + title: 'Cancel editing?', + description: <>Changes will not be saved., + buttons: [ + { + label: 'Yes', + buttonType: 'mini-black', + onClick: () => { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p[comment.id] || {}), + isEditing: false, + editDraft: '', + }, + })); + setIsGloballyEditing(false); + clearEditingLocalStorage(comment.id, ContentType.Comment); + }, + }, + { + label: 'No', + buttonType: 'mini-white', + }, + ], + }); + } else { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p[comment.id] || {}), + isEditing: false, + editDraft: '', + }, + })); + setIsGloballyEditing(false); + } + }; + + const handleEditStart = (comment: CommentType) => { + const editDraft = localStorage.getItem( + `${app.activeChainId()}-edit-comment-${comment.id}-storedText` + ); + if (editDraft) { + clearEditingLocalStorage(comment.id, ContentType.Comment); + const body = deserializeDelta(editDraft); + + openConfirmation({ + title: 'Info', + description: <>Previous changes found. Restore edits?, + buttons: [ + { + label: 'Restore', + buttonType: 'mini-black', + onClick: () => { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p?.[comment.id] || {}), + isEditing: true, + editDraft: editDraft, + contentDelta: body, + }, + })); + setIsGloballyEditing(true); + }, + }, + { + label: 'Cancel', + buttonType: 'mini-white', + onClick: () => { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p?.[comment.id] || {}), + isEditing: true, + editDraft: '', + contentDelta: body, + }, + })); + setIsGloballyEditing(true); + }, + }, + ], + }); + } else { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p?.[comment.id] || {}), + isEditing: true, + editDraft: '', + contentDelta: deserializeDelta(comment.text), + }, + })); + setIsGloballyEditing(true); + } + }; + + const handleEditConfirm = async ( + comment: CommentType, + newDelta: DeltaStatic + ) => { + { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p[comment.id] || {}), + isSavingEdit: true, + }, + })); + + try { + await app.comments.edit(comment, serializeDelta(newDelta)); + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p[comment.id] || {}), + isEditing: false, + }, + })); + setIsGloballyEditing(false); + clearEditingLocalStorage(comment.id, ContentType.Comment); + updatedCommentsCallback(); + } catch (err) { + console.error(err); + } finally { + setEdits((p) => ({ + ...p, + [comment.id]: { + ...(p[comment.id] || {}), + isSavingEdit: false, + }, + })); + } + } + }; + + const handleFlagMarkAsSpam = (comment: CommentType) => { + openConfirmation({ + title: !comment.markedAsSpamAt + ? 'Confirm flag as spam' + : 'Unflag as spam?', + description: !comment.markedAsSpamAt ? ( + <> +

Are you sure you want to flag this comment as spam?

+
+

+ Flagging as spam will help filter out unwanted content. Comments + flagged as spam are hidden from the main feed and can't be + interacted with. For transparency, spam can still be viewed by + community members if they choose to "Include comments flagged as + spam." +

+
+

Note that you can always unflag a comment as spam.

+ + ) : ( + <> +

+ Are you sure you want to unflag this comment as spam? Flagging as + spam will help filter out unwanted content. +

+
+

+ For transparency, spam can still be viewed by community members if + they choose to “Include comments flagged as spam.” +
+

+ + ), + buttons: [ + { + label: 'Cancel', + buttonType: 'mini-black', + }, + { + label: !comment.markedAsSpamAt ? 'Confirm' : 'Unflag as spam?', + buttonType: 'mini-red', + onClick: async () => { + try { + app.comments + .toggleSpam(comment.id, !!comment.markedAsSpamAt) + .then(() => { + updatedCommentsCallback && updatedCommentsCallback(); + }); + } catch (err) { + console.log(err); + } + }, + }, + ], + }); + }; + + const recursivelyGatherComments = ( + comments_: CommentType[], + parentComment: CommentType, + threadLevel: number + ) => { + const canContinueThreading = threadLevel <= MAX_THREAD_LEVEL; + + return comments_ + .filter((x) => (includeSpams ? true : !x.markedAsSpamAt)) + .map((comment: CommentType) => { + const children = app.comments + .getByThread(thread) + .filter((c) => c.parentComment === comment.id); + + if (isLivingCommentTree(comment, children)) { + const isCommentAuthor = + comment.author === app.user.activeAccount?.address; + + const isLast = threadLevel === 8; + + const replyBtnVisible = !!(!isLast && !isLocked && isLoggedIn); + + return ( + +
+ {threadLevel > 0 && ( +
+ {Array(threadLevel) + .fill(undefined) + .map((_, i) => ( +
+ ))} +
+ )} + await handleEditStart(comment)} + onEditCancel={async (hasContentChanged: boolean) => + await handleEditCancel(comment, hasContentChanged) + } + onEditConfirm={async (newDelta) => + await handleEditConfirm(comment, newDelta) + } + isSavingEdit={edits?.[comment.id]?.isSavingEdit || false} + isEditing={edits?.[comment.id]?.isEditing || false} + canDelete={!isLocked && (isCommentAuthor || isAdminOrMod)} + replyBtnVisible={replyBtnVisible} + onReply={() => { + setParentCommentId(comment.id); + setIsReplying(true); + }} + onDelete={async () => await handleDeleteComment(comment)} + isSpam={!!comment.markedAsSpamAt} + onSpamToggle={async () => await handleFlagMarkAsSpam(comment)} + canToggleSpam={!isLocked && (isCommentAuthor || isAdminOrMod)} + comment={comment} + /> +
+ {isReplying && parentCommentId === comment.id && ( + + )} + {!!children.length && + canContinueThreading && + recursivelyGatherComments(children, comment, threadLevel + 1)} + + ); + } else { + return null; + } + }); + }; + + return ( +
+ {comments && recursivelyGatherComments(comments, comments[0], 0)} + {commentError && ( + + )} +
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/index.tsx index 4c5c1047534..2641a83e4c7 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/index.tsx @@ -1,456 +1,3 @@ -import useUserLoggedIn from 'hooks/useUserLoggedIn'; -import type { DeltaStatic } from 'quill'; -import React, { useEffect, useState } from 'react'; -import app from 'state'; -import { ContentType } from 'types'; -import { openConfirmation } from 'views/modals/confirmation_modal'; -import { notifyError } from '../../../../controllers/app/notifications'; -import type { Comment as CommentType } from '../../../../models/Comment'; -import Thread from '../../../../models/Thread'; -import Permissions from '../../../../utils/Permissions'; -import { CreateComment } from '../../../components/Comments/CreateComment'; -import { CWValidationText } from '../../../components/component_kit/cw_validation_text'; -import { - deserializeDelta, - serializeDelta, -} from '../../../components/react_quill_editor/utils'; -import { CommentCard } from '../CommentCard'; -import { clearEditingLocalStorage } from '../CommentTree/helpers'; -import { jumpHighlightComment } from './helpers'; -import './index.scss'; +import { CommentTree } from './CommentTree'; -const MAX_THREAD_LEVEL = 8; - -type CommentsTreeAttrs = { - comments: Array>; - thread: Thread; - setIsGloballyEditing?: (status: boolean) => void; - updatedCommentsCallback: () => void; - includeSpams: boolean; - isReplying: boolean; - setIsReplying: (status: boolean) => void; - parentCommentId: number; - setParentCommentId: (id: number) => void; - canComment: boolean; -}; - -export const CommentsTree = ({ - comments, - thread, - setIsGloballyEditing, - updatedCommentsCallback, - includeSpams, - isReplying, - setIsReplying, - parentCommentId, - setParentCommentId, - canComment, -}: CommentsTreeAttrs) => { - const [commentError] = useState(null); - const [highlightedComment, setHighlightedComment] = useState(false); - - const [edits, setEdits] = useState<{ - [commentId: number]: { - isEditing?: boolean; - editDraft?: string; - isSavingEdit?: boolean; - contentDelta?: DeltaStatic; - }; - }>(); - - const { isLoggedIn } = useUserLoggedIn(); - - const isAdminOrMod = - Permissions.isSiteAdmin() || - Permissions.isCommunityAdmin() || - Permissions.isCommunityModerator(); - - const isLocked = !!(thread instanceof Thread && thread.readOnly); - - useEffect(() => { - if (comments?.length > 0 && !highlightedComment) { - setHighlightedComment(true); - - const commentId = window.location.search.startsWith('?comment=') - ? window.location.search.replace('?comment=', '') - : null; - - if (commentId) jumpHighlightComment(Number(commentId)); - } - }, [comments?.length, highlightedComment]); - - // eslint-disable-next-line @typescript-eslint/no-shadow - const handleIsReplying = (isReplying: boolean, id?: number) => { - if (isReplying) { - setParentCommentId(id); - setIsReplying(true); - } else { - setParentCommentId(undefined); - setIsReplying(false); - } - }; - - const isLivingCommentTree = (comment, children) => { - if (!comment.deleted) { - return true; - } else if (!children.length) { - return false; - } else { - let survivingDescendents = false; - - for (let i = 0; i < children.length; i++) { - const child = children[i]; - - if (!child.deleted) { - survivingDescendents = true; - break; - } - - const grandchildren = app.comments - .getByThread(thread) - .filter((c) => c.parentComment === child.id); - - for (let j = 0; j < grandchildren.length; j++) { - const grandchild = grandchildren[j]; - - if (!grandchild.deleted) { - survivingDescendents = true; - break; - } - } - - if (survivingDescendents) break; - } - - return survivingDescendents; - } - }; - - const handleDeleteComment = (comment: CommentType) => { - openConfirmation({ - title: 'Delete Comment', - description: <>Delete this comment?, - buttons: [ - { - label: 'Delete', - buttonType: 'mini-red', - onClick: async () => { - try { - await app.comments.delete(comment, thread.id); - updatedCommentsCallback(); - } catch (e) { - console.log(e); - notifyError('Failed to delete comment.'); - } - }, - }, - { - label: 'Cancel', - buttonType: 'mini-black', - }, - ], - }); - }; - - const handleEditCancel = ( - comment: CommentType, - hasContentChanged: boolean - ) => { - if (hasContentChanged) { - openConfirmation({ - title: 'Cancel editing?', - description: <>Changes will not be saved., - buttons: [ - { - label: 'Yes', - buttonType: 'mini-black', - onClick: () => { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p[comment.id] || {}), - isEditing: false, - editDraft: '', - }, - })); - setIsGloballyEditing(false); - clearEditingLocalStorage(comment.id, ContentType.Comment); - }, - }, - { - label: 'No', - buttonType: 'mini-white', - }, - ], - }); - } else { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p[comment.id] || {}), - isEditing: false, - editDraft: '', - }, - })); - setIsGloballyEditing(false); - } - }; - - const handleEditStart = (comment: CommentType) => { - const editDraft = localStorage.getItem( - `${app.activeChainId()}-edit-comment-${comment.id}-storedText` - ); - if (editDraft) { - clearEditingLocalStorage(comment.id, ContentType.Comment); - const body = deserializeDelta(editDraft); - - openConfirmation({ - title: 'Info', - description: <>Previous changes found. Restore edits?, - buttons: [ - { - label: 'Restore', - buttonType: 'mini-black', - onClick: () => { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p?.[comment.id] || {}), - isEditing: true, - editDraft: editDraft, - contentDelta: body, - }, - })); - setIsGloballyEditing(true); - }, - }, - { - label: 'Cancel', - buttonType: 'mini-white', - onClick: () => { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p?.[comment.id] || {}), - isEditing: true, - editDraft: '', - contentDelta: body, - }, - })); - setIsGloballyEditing(true); - }, - }, - ], - }); - } else { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p?.[comment.id] || {}), - isEditing: true, - editDraft: '', - contentDelta: deserializeDelta(comment.text), - }, - })); - setIsGloballyEditing(true); - } - }; - - const handleEditConfirm = async ( - comment: CommentType, - newDelta: DeltaStatic - ) => { - { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p[comment.id] || {}), - isSavingEdit: true, - }, - })); - - try { - await app.comments.edit(comment, serializeDelta(newDelta)); - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p[comment.id] || {}), - isEditing: false, - }, - })); - setIsGloballyEditing(false); - clearEditingLocalStorage(comment.id, ContentType.Comment); - updatedCommentsCallback(); - } catch (err) { - console.error(err); - } finally { - setEdits((p) => ({ - ...p, - [comment.id]: { - ...(p[comment.id] || {}), - isSavingEdit: false, - }, - })); - } - } - }; - - const handleFlagMarkAsSpam = (comment: CommentType) => { - openConfirmation({ - title: !comment.markedAsSpamAt - ? 'Confirm flag as spam' - : 'Unflag as spam?', - description: !comment.markedAsSpamAt ? ( - <> -

Are you sure you want to flag this comment as spam?

-
-

- Flagging as spam will help filter out unwanted content. Comments - flagged as spam are hidden from the main feed and can't be - interacted with. For transparency, spam can still be viewed by - community members if they choose to "Include comments flagged as - spam." -

-
-

Note that you can always unflag a comment as spam.

- - ) : ( - <> -

- Are you sure you want to unflag this comment as spam? Flagging as - spam will help filter out unwanted content. -

-
-

- For transparency, spam can still be viewed by community members if - they choose to “Include comments flagged as spam.” -
-

- - ), - buttons: [ - { - label: 'Cancel', - buttonType: 'mini-black', - }, - { - label: !comment.markedAsSpamAt ? 'Confirm' : 'Unflag as spam?', - buttonType: 'mini-red', - onClick: async () => { - try { - app.comments - .toggleSpam(comment.id, !!comment.markedAsSpamAt) - .then(() => { - updatedCommentsCallback && updatedCommentsCallback(); - }); - } catch (err) { - console.log(err); - } - }, - }, - ], - }); - }; - - const recursivelyGatherComments = ( - comments_: CommentType[], - parentComment: CommentType, - threadLevel: number - ) => { - const canContinueThreading = threadLevel <= MAX_THREAD_LEVEL; - - return comments_ - .filter((x) => (includeSpams ? true : !x.markedAsSpamAt)) - .map((comment: CommentType) => { - const children = app.comments - .getByThread(thread) - .filter((c) => c.parentComment === comment.id); - - if (isLivingCommentTree(comment, children)) { - const isCommentAuthor = - comment.author === app.user.activeAccount?.address; - - const isLast = threadLevel === 8; - - const canReply = !!( - !isLast && - !isLocked && - isLoggedIn && - app.user.activeAccount - ); - - return ( - -
- {threadLevel > 0 && ( -
- {Array(threadLevel) - .fill(undefined) - .map((_, i) => ( -
- ))} -
- )} - await handleEditStart(comment)} - onEditCancel={async (hasContentChanged: boolean) => - await handleEditCancel(comment, hasContentChanged) - } - onEditConfirm={async (newDelta) => - await handleEditConfirm(comment, newDelta) - } - isSavingEdit={edits?.[comment.id]?.isSavingEdit || false} - isEditing={edits?.[comment.id]?.isEditing || false} - canDelete={!isLocked && (isCommentAuthor || isAdminOrMod)} - canReply={canReply} - onReply={() => { - setParentCommentId(comment.id); - setIsReplying(true); - }} - onDelete={async () => await handleDeleteComment(comment)} - isSpam={!!comment.markedAsSpamAt} - onSpamToggle={async () => await handleFlagMarkAsSpam(comment)} - canToggleSpam={!isLocked && (isCommentAuthor || isAdminOrMod)} - comment={comment} - /> -
- {isReplying && parentCommentId === comment.id && ( - - )} - {!!children.length && - canContinueThreading && - recursivelyGatherComments(children, comment, threadLevel + 1)} - - ); - } else { - return null; - } - }); - }; - - return ( -
- {comments && recursivelyGatherComments(comments, comments[0], 0)} - {commentError && ( - - )} -
- ); -}; +export { CommentTree }; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.scss similarity index 100% rename from packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/index.scss rename to packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.scss diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx new file mode 100644 index 00000000000..f4889dd186c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx @@ -0,0 +1,355 @@ +import { parseCustomStages, threadStageToLabel } from 'helpers'; +import { isUndefined } from 'helpers/typeGuards'; +import useBrowserWindow from 'hooks/useBrowserWindow'; +import useForceRerender from 'hooks/useForceRerender'; +import { useCommonNavigate } from 'navigation/helpers'; +import React, { useEffect, useRef, useState } from 'react'; +import { matchRoutes } from 'react-router-dom'; +import app from 'state'; +import { useFetchTopicsQuery } from 'state/api/topics'; +import { Modal } from 'views/components/component_kit/cw_modal'; +import { EditTopicModal } from 'views/modals/edit_topic_modal'; +import type Topic from '../../../../models/Topic'; +import { + ThreadFeaturedFilterTypes, + ThreadStage, + ThreadTimelineFilterTypes, +} from '../../../../models/types'; +import { CWButton } from 'views/components/component_kit/cw_button'; +import { CWCheckbox } from 'views/components/component_kit/cw_checkbox'; +import { CWIconButton } from 'views/components/component_kit/cw_icon_button'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { Select } from 'views/components/Select'; +import './HeaderWithFilters.scss'; +import useUserActiveAccount from 'hooks/useUserActiveAccount'; + +type HeaderWithFiltersProps = { + stage: string; + topic: string; + featuredFilter: ThreadFeaturedFilterTypes; + dateRange: ThreadTimelineFilterTypes; + totalThreadCount: number; + isIncludingSpamThreads: boolean; + onIncludeSpamThreads: (includeSpams: boolean) => any; +}; + +export const HeaderWithFilters = ({ + stage, + topic, + featuredFilter, + dateRange, + totalThreadCount, + isIncludingSpamThreads, + onIncludeSpamThreads, +}: HeaderWithFiltersProps) => { + const navigate = useCommonNavigate(); + const [topicSelectedToEdit, setTopicSelectedToEdit] = useState(null); + const forceRerender = useForceRerender(); + const filterRowRef = useRef(); + const [rightFiltersDropdownPosition, setRightFiltersDropdownPosition] = + useState<'bottom-end' | 'bottom-start'>('bottom-end'); + + const { activeAccount: hasJoinedCommunity } = useUserActiveAccount(); + + const onFilterResize = () => { + if (filterRowRef.current) { + setRightFiltersDropdownPosition( + filterRowRef.current.clientHeight > 40 ? 'bottom-start' : 'bottom-end' + ); + } + }; + + useBrowserWindow({ + onResize: onFilterResize, + resizeListenerUpdateDeps: [], + }); + + useEffect(() => { + onFilterResize(); + }, []); + + const { isWindowExtraSmall } = useBrowserWindow({}); + + useEffect(() => { + app.loginStateEmitter.on('redraw', forceRerender); + + return () => { + app.loginStateEmitter.off('redraw', forceRerender); + }; + }, [forceRerender]); + + const { stagesEnabled, customStages } = app.chain?.meta || {}; + + const { data: topics } = useFetchTopicsQuery({ + chainId: app.activeChainId(), + }); + + const featuredTopics = (topics || []) + .filter((t) => t.featuredInSidebar) + .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => a.order - b.order); + + const otherTopics = (topics || []) + .filter((t) => !t.featuredInSidebar) + .sort((a, b) => a.name.localeCompare(b.name)); + + const selectedTopic = (topics || []).find((t) => topic && topic === t.name); + + const stages = !customStages + ? [ + ThreadStage.Discussion, + ThreadStage.ProposalInReview, + ThreadStage.Voting, + ThreadStage.Passed, + ThreadStage.Failed, + ] + : parseCustomStages(customStages); + + const selectedStage = stages.find((s) => s === (stage as ThreadStage)); + + const matchesDiscussionsTopicRoute = matchRoutes( + [{ path: '/discussions/:topic' }, { path: ':scope/discussions/:topic' }], + location + ); + + const onFilterSelect = ({ + pickedTopic = matchesDiscussionsTopicRoute?.[0]?.params?.topic || '', + filterKey = '', + filterVal = '', + }) => { + if ( + filterKey === 'featured' && + filterVal !== (featuredFilter || ThreadFeaturedFilterTypes.Newest) + ) { + // Remove threads from state whenever the featured filter changes + // This prevents the situation when we have less data in state and + // we use that to show the applied featured filter data which would + // not be accurate - whenever "featured" filter changes we have to + // refetch fresh threads data from api. + app.threads.store.clear(); + app.threads.listingStore.clear(); + app.threads.numTotalThreads = 0; + } + + const urlParams = Object.fromEntries( + new URLSearchParams(window.location.search) + ); + urlParams[filterKey] = filterVal; + + if (filterVal === '') { + delete urlParams[filterKey]; + } + + navigate( + `/discussions${pickedTopic ? `/${pickedTopic}` : ''}?` + + Object.keys(urlParams) + .map((x) => `${x}=${urlParams[x]}`) + .join('&') + ); + }; + + return ( +
+
+ + {isUndefined(topic) ? 'All Discussions' : topic} + +
+ + {totalThreadCount} Threads + + {isWindowExtraSmall ? ( + { + navigate('/new/discussion'); + }} + disabled={!hasJoinedCommunity} + /> + ) : ( + { + navigate( + `/new/discussion${topic ? `?topic=${selectedTopic?.id}` : ''}` + ); + }} + disabled={!hasJoinedCommunity} + /> + )} +
+
+ + {selectedTopic?.description && ( + {selectedTopic.description} + )} + + {app.chain?.meta && ( +
+
+

Sort

+ + onFilterSelect({ + pickedTopic: item === 'All Topics' ? '' : item.value, + }) + } + options={[ + { + id: 0, + label: 'All Topics', + value: 'All Topics', + }, + ...[...featuredTopics, ...otherTopics].map((t) => ({ + id: t.id, + value: t.name, + label: t.name, + })), + ]} + dropdownPosition={rightFiltersDropdownPosition} + canEditOption={app.roles?.isAdminOfEntity({ + chain: app.activeChainId(), + })} + onOptionEdit={(item: any) => + setTopicSelectedToEdit( + [...featuredTopics, ...otherTopics].find( + (x) => x.id === item.id + ) + ) + } + /> + )} + {stagesEnabled && ( + { + onFilterSelect({ + filterKey: 'dateRange', + filterVal: item.value as ThreadTimelineFilterTypes, + }); + }} + options={[ + { + id: 1, + value: ThreadTimelineFilterTypes.AllTime, + label: 'All Time', + }, + { + id: 2, + value: ThreadTimelineFilterTypes.ThisMonth, + label: 'Month', + }, + { + id: 3, + value: ThreadTimelineFilterTypes.ThisWeek, + label: 'Week', + }, + ]} + dropdownPosition={rightFiltersDropdownPosition} + /> +
+
+
+ )} + + { + onIncludeSpamThreads(e.target.checked); + }} + /> + + setTopicSelectedToEdit(null)} + /> + } + onClose={() => setTopicSelectedToEdit(null)} + open={!!topicSelectedToEdit} + /> +
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/index.tsx index 8c0d4ebeb96..443549bf13a 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/index.tsx @@ -1,354 +1,3 @@ -import { parseCustomStages, threadStageToLabel } from 'helpers'; -import { isUndefined } from 'helpers/typeGuards'; -import useBrowserWindow from 'hooks/useBrowserWindow'; -import useForceRerender from 'hooks/useForceRerender'; -import { useCommonNavigate } from 'navigation/helpers'; -import React, { useEffect, useRef, useState } from 'react'; -import { matchRoutes } from 'react-router-dom'; -import app from 'state'; -import { useFetchTopicsQuery } from 'state/api/topics'; -import { Modal } from 'views/components/component_kit/cw_modal'; -import { EditTopicModal } from 'views/modals/edit_topic_modal'; -import type Topic from '../../../../models/Topic'; -import { - ThreadFeaturedFilterTypes, - ThreadStage, - ThreadTimelineFilterTypes, -} from '../../../../models/types'; -import { CWButton } from '../../../components/component_kit/cw_button'; -import { CWCheckbox } from '../../../components/component_kit/cw_checkbox'; -import { CWIconButton } from '../../../components/component_kit/cw_icon_button'; -import { CWText } from '../../../components/component_kit/cw_text'; -import { Select } from '../../../components/Select'; -import './index.scss'; +import { HeaderWithFilters } from './HeaderWithFilters'; -type HeaderWithFiltersProps = { - stage: string; - topic: string; - featuredFilter: ThreadFeaturedFilterTypes; - dateRange: ThreadTimelineFilterTypes; - totalThreadCount: number; - isIncludingSpamThreads: boolean; - onIncludeSpamThreads: (includeSpams: boolean) => any; -}; - -export const HeaderWithFilters = ({ - stage, - topic, - featuredFilter, - dateRange, - totalThreadCount, - isIncludingSpamThreads, - onIncludeSpamThreads, -}: HeaderWithFiltersProps) => { - const navigate = useCommonNavigate(); - const [topicSelectedToEdit, setTopicSelectedToEdit] = useState(null); - const forceRerender = useForceRerender(); - const filterRowRef = useRef(); - const [rightFiltersDropdownPosition, setRightFiltersDropdownPosition] = - useState<'bottom-end' | 'bottom-start'>('bottom-end'); - - const onFilterResize = () => { - if (filterRowRef.current) { - setRightFiltersDropdownPosition( - filterRowRef.current.clientHeight > 40 ? 'bottom-start' : 'bottom-end' - ); - } - }; - - useBrowserWindow({ - onResize: onFilterResize, - resizeListenerUpdateDeps: [], - }); - - useEffect(() => { - onFilterResize(); - }, []); - - const { isWindowExtraSmall } = useBrowserWindow({}); - - useEffect(() => { - app.loginStateEmitter.on('redraw', forceRerender); - app.user.isFetched.on('redraw', forceRerender); - - return () => { - app.loginStateEmitter.off('redraw', forceRerender); - app.user.isFetched.off('redraw', forceRerender); - }; - }, [forceRerender]); - - const { stagesEnabled, customStages } = app.chain?.meta || {}; - - const { data: topics } = useFetchTopicsQuery({ - chainId: app.activeChainId(), - }); - - const featuredTopics = (topics || []) - .filter((t) => t.featuredInSidebar) - .sort((a, b) => a.name.localeCompare(b.name)) - .sort((a, b) => a.order - b.order); - - const otherTopics = (topics || []) - .filter((t) => !t.featuredInSidebar) - .sort((a, b) => a.name.localeCompare(b.name)); - - const selectedTopic = (topics || []).find((t) => topic && topic === t.name); - - const stages = !customStages - ? [ - ThreadStage.Discussion, - ThreadStage.ProposalInReview, - ThreadStage.Voting, - ThreadStage.Passed, - ThreadStage.Failed, - ] - : parseCustomStages(customStages); - - const selectedStage = stages.find((s) => s === (stage as ThreadStage)); - - const matchesDiscussionsTopicRoute = matchRoutes( - [{ path: '/discussions/:topic' }, { path: ':scope/discussions/:topic' }], - location - ); - - const onFilterSelect = ({ - pickedTopic = matchesDiscussionsTopicRoute?.[0]?.params?.topic || '', - filterKey = '', - filterVal = '', - }) => { - if ( - filterKey === 'featured' && - filterVal !== (featuredFilter || ThreadFeaturedFilterTypes.Newest) - ) { - // Remove threads from state whenever the featured filter changes - // This prevents the situation when we have less data in state and - // we use that to show the applied featured filter data which would - // not be accurate - whenever "featured" filter changes we have to - // refetch fresh threads data from api. - app.threads.store.clear(); - app.threads.listingStore.clear(); - app.threads.numTotalThreads = 0; - } - - const urlParams = Object.fromEntries( - new URLSearchParams(window.location.search) - ); - urlParams[filterKey] = filterVal; - - if (filterVal === '') { - delete urlParams[filterKey]; - } - - navigate( - `/discussions${pickedTopic ? `/${pickedTopic}` : ''}?` + - Object.keys(urlParams) - .map((x) => `${x}=${urlParams[x]}`) - .join('&') - ); - }; - - return ( -
-
- - {isUndefined(topic) ? 'All Discussions' : topic} - -
- - {totalThreadCount} Threads - - {isWindowExtraSmall ? ( - { - navigate('/new/discussion'); - }} - disabled={!app.user.activeAccount} - /> - ) : ( - { - navigate( - `/new/discussion${topic ? `?topic=${selectedTopic?.id}` : ''}` - ); - }} - disabled={!app.user.activeAccount} - /> - )} -
-
- - {selectedTopic?.description && ( - {selectedTopic.description} - )} - - {app.chain?.meta && ( -
-
-

Sort

- - onFilterSelect({ - pickedTopic: item === 'All Topics' ? '' : item.value, - }) - } - options={[ - { - id: 0, - label: 'All Topics', - value: 'All Topics', - }, - ...[...featuredTopics, ...otherTopics].map((t) => ({ - id: t.id, - value: t.name, - label: t.name, - })), - ]} - dropdownPosition={rightFiltersDropdownPosition} - canEditOption={app.roles?.isAdminOfEntity({ - chain: app.activeChainId(), - })} - onOptionEdit={(item: any) => - setTopicSelectedToEdit( - [...featuredTopics, ...otherTopics].find( - (x) => x.id === item.id - ) - ) - } - /> - )} - {stagesEnabled && ( - { - onFilterSelect({ - filterKey: 'dateRange', - filterVal: item.value as ThreadTimelineFilterTypes, - }); - }} - options={[ - { - id: 1, - value: ThreadTimelineFilterTypes.AllTime, - label: 'All Time', - }, - { - id: 2, - value: ThreadTimelineFilterTypes.ThisMonth, - label: 'Month', - }, - { - id: 3, - value: ThreadTimelineFilterTypes.ThisWeek, - label: 'Week', - }, - ]} - dropdownPosition={rightFiltersDropdownPosition} - /> -
-
-
- )} - - { - onIncludeSpamThreads(e.target.checked); - }} - /> - - setTopicSelectedToEdit(null)} - /> - } - onClose={() => setTopicSelectedToEdit(null)} - open={!!topicSelectedToEdit} - /> -
- ); -}; +export { HeaderWithFilters }; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/AuthorAndPublishInfo.scss similarity index 100% rename from packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/index.scss rename to packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/AuthorAndPublishInfo.scss diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/AuthorAndPublishInfo.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/AuthorAndPublishInfo.tsx new file mode 100644 index 00000000000..3d01400f6e3 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/AuthorAndPublishInfo.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { threadStageToLabel } from 'helpers'; +import type Account from '../../../../../models/Account'; +import AddressInfo from '../../../../../models/AddressInfo'; +import MinimumProfile from '../../../../../models/MinimumProfile'; +import { IThreadCollaborator } from '../../../../../models/Thread'; +import { ThreadStage } from '../../../../../models/types'; +import { + Popover, + usePopover, +} from 'views/components/component_kit/cw_popover/cw_popover'; +import { CWTag } from 'views/components/component_kit/cw_tag'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { getClasses } from 'views/components/component_kit/helpers'; +import { User } from 'views/components/user/user'; +import './AuthorAndPublishInfo.scss'; +import { LockWithTooltip } from 'views/components/lock_with_tooltip'; +import moment from 'moment'; + +export type AuthorAndPublishInfoProps = { + isNew?: boolean; + authorInfo: Account | AddressInfo | MinimumProfile | undefined; + collaboratorsInfo?: IThreadCollaborator[]; + isLocked?: boolean; + lockedAt?: string; + lastUpdated?: string; + publishDate?: string; + viewsCount?: number; + showSplitDotIndicator?: boolean; + showPublishLabelWithDate?: boolean; + showEditedLabelWithDate?: boolean; + showUserAddressWithInfo?: boolean; + isSpamThread?: boolean; + threadStage?: ThreadStage; + onThreadStageLabelClick?: (threadStage: ThreadStage) => Promise; +}; + +export const AuthorAndPublishInfo = ({ + isNew, + authorInfo, + isLocked, + lockedAt, + lastUpdated, + viewsCount, + publishDate, + showSplitDotIndicator = true, + showPublishLabelWithDate, + showEditedLabelWithDate, + isSpamThread, + showUserAddressWithInfo = true, + threadStage, + onThreadStageLabelClick, + collaboratorsInfo, +}: AuthorAndPublishInfoProps) => { + const popoverProps = usePopover(); + + const dotIndicator = showSplitDotIndicator && ( + + ); + + return ( +
+ + + {collaboratorsInfo?.length > 0 && ( + <> + and + + {`${collaboratorsInfo.length} other${ + collaboratorsInfo.length > 1 ? 's' : '' + }`} + + {collaboratorsInfo.map(({ address, chain }) => { + return ( + + ); + })} +
+ } + {...popoverProps} + /> + + + )} + + {publishDate && ( + <> + {dotIndicator} + + {showPublishLabelWithDate ? 'Published on ' : ''} + {showEditedLabelWithDate ? 'Edited on ' : ''} + {publishDate} + + + )} + + {viewsCount >= 0 && ( + <> + {dotIndicator} + + {`${viewsCount} view${viewsCount > 1 ? 's' : ''}`} + + + )} + + {threadStage && ( + <> + {dotIndicator} + ( + { + stage: + threadStage === ThreadStage.Failed ? 'negative' : 'positive', + }, + 'proposal-stage-text' + )} + onClick={async () => await onThreadStageLabelClick(threadStage)} + > + {threadStageToLabel(threadStage)} + + + )} + + {isNew && ( + <> + {dotIndicator} + + + )} + + {isSpamThread && ( + <> + {dotIndicator} + + + )} + + {isLocked && lockedAt && lastUpdated && ( + <> + {dotIndicator} + + + )} +
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/index.tsx index a429b284a66..ceaad97b907 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/AuthorAndPublishInfo/index.tsx @@ -1,168 +1,3 @@ -import React from 'react'; -import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; -import { threadStageToLabel } from '../../../../../helpers/index'; -import type Account from '../../../../../models/Account'; -import AddressInfo from '../../../../../models/AddressInfo'; -import MinimumProfile from '../../../../../models/MinimumProfile'; -import { IThreadCollaborator } from '../../../../../models/Thread'; -import { ThreadStage } from '../../../../../models/types'; -import { - Popover, - usePopover, -} from '../../../../components/component_kit/cw_popover/cw_popover'; -import { CWTag } from '../../../../components/component_kit/cw_tag'; -import { CWText } from '../../../../components/component_kit/cw_text'; -import { getClasses } from '../../../../components/component_kit/helpers'; -import { User } from '../../../../components/user/user'; -import './index.scss'; -import { LockWithTooltip } from '../../../../components/lock_with_tooltip'; -import moment from 'moment'; +import { AuthorAndPublishInfo } from './AuthorAndPublishInfo'; -export type AuthorAndPublishInfoProps = { - isNew?: boolean; - authorInfo: Account | AddressInfo | MinimumProfile | undefined; - collaboratorsInfo?: IThreadCollaborator[]; - isLocked?: boolean; - lockedAt?: string; - lastUpdated?: string; - publishDate?: string; - viewsCount?: number; - showSplitDotIndicator?: boolean; - showPublishLabelWithDate?: boolean; - showEditedLabelWithDate?: boolean; - showUserAddressWithInfo?: boolean; - isSpamThread?: boolean; - threadStage?: ThreadStage; - onThreadStageLabelClick?: (threadStage: ThreadStage) => Promise; -}; - -export const AuthorAndPublishInfo = ({ - isNew, - authorInfo, - isLocked, - lockedAt, - lastUpdated, - viewsCount, - publishDate, - showSplitDotIndicator = true, - showPublishLabelWithDate, - showEditedLabelWithDate, - isSpamThread, - showUserAddressWithInfo = true, - threadStage, - onThreadStageLabelClick, - collaboratorsInfo, -}: AuthorAndPublishInfoProps) => { - const popoverProps = usePopover(); - - const dotIndicator = showSplitDotIndicator && ( - - ); - - return ( -
- - - {collaboratorsInfo?.length > 0 && ( - <> - and - - {`${collaboratorsInfo.length} other${ - collaboratorsInfo.length > 1 ? 's' : '' - }`} - - {collaboratorsInfo.map(({ address, chain }) => { - return ( - - ); - })} -
- } - {...popoverProps} - /> - - - )} - - {publishDate && ( - <> - {dotIndicator} - - {showPublishLabelWithDate ? 'Published on ' : ''} - {showEditedLabelWithDate ? 'Edited on ' : ''} - {publishDate} - - - )} - - {viewsCount >= 0 && ( - <> - {dotIndicator} - - {`${viewsCount} view${viewsCount > 1 ? 's' : ''}`} - - - )} - - {threadStage && ( - <> - {dotIndicator} - ( - { - stage: - threadStage === ThreadStage.Failed ? 'negative' : 'positive', - }, - 'proposal-stage-text' - )} - onClick={async () => await onThreadStageLabelClick(threadStage)} - > - {threadStageToLabel(threadStage)} - - - )} - - {isNew && ( - <> - {dotIndicator} - - - )} - - {isSpamThread && ( - <> - {dotIndicator} - - - )} - - {isLocked && lockedAt && lastUpdated && ( - <> - {dotIndicator} - - - )} - - ); -}; +export { AuthorAndPublishInfo }; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/AdminActions/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/AdminActions/index.scss deleted file mode 100644 index 5448b7d7fc9..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/AdminActions/index.scss +++ /dev/null @@ -1,36 +0,0 @@ -@import '../../../../../../../styles/shared'; - -.AdminActions { - .danger { - color: $rorange-600 !important; - - .Icon { - color: $rorange-600 !important; - } - - .Text { - color: $rorange-600 !important; - } - } -} - -.action-btn { - outline: 0; - border: 0; - display: flex; - justify-content: center; - align-items: center; - gap: 4px; - cursor: pointer; - padding: 4px 8px; - border-radius: $border-radius-corners-wider; - color: $neutral-600; - - &:hover { - background-color: $neutral-200; - } - - .Text { - color: $neutral-600; - } -} \ No newline at end of file diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/index.scss deleted file mode 100644 index eb683c30681..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/index.scss +++ /dev/null @@ -1,28 +0,0 @@ -@import '../../../../../../styles/shared'; - -.Options { - align-items: center; - display: flex; - gap: 8px; - - .thread-option-btn { - outline: 0; - border: 0; - display: flex; - justify-content: center; - align-items: center; - gap: 4px; - cursor: pointer; - padding: 4px 8px; - border-radius: $border-radius-corners-wider; - color: $neutral-600; - - &:hover { - background-color: $neutral-200; - } - - .Text { - color: $neutral-600; - } - } -} \ No newline at end of file diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/index.tsx deleted file mode 100644 index 25223fe63ae..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import Thread from 'models/Thread'; -import React, { useState } from 'react'; -import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; -import { CWText } from '../../../../components/component_kit/cw_text'; -import { SharePopover } from '../../../../components/share_popover'; -import { - getCommentSubscription, - getReactionSubscription, - handleToggleSubscription, -} from '../../helpers'; -import { AdminActions, AdminActionsProps } from './AdminActions'; -import { ReactionButton } from './ReactionButton'; -import './index.scss'; - -type OptionsProps = AdminActionsProps & { - thread?: Thread; - canVote?: boolean; - canComment?: boolean; - shareEndpoint?: string; - canUpdateThread?: boolean; - totalComments?: number; -}; - -export const Options = ({ - thread, - canVote = false, - canComment = true, - shareEndpoint, - canUpdateThread, - totalComments, - onLockToggle, - onTopicChange, - onCollaboratorsEdit, - onDelete, - onEditStart, - onEditCancel, - onEditConfirm, - onPinToggle, - onProposalStageChange, - onSnapshotProposalFromThread, - onSpamToggle, - hasPendingEdits, -}: OptionsProps) => { - const [isSubscribed, setIsSubscribed] = useState( - thread && - getCommentSubscription(thread)?.isActive && - getReactionSubscription(thread)?.isActive - ); - - return ( - <> -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - {canVote && thread && } - - {canComment && totalComments >= 0 && ( - - )} - - ( - - )} - /> - - - - {canUpdateThread && thread && ( - - )} -
- {thread && <>} - - ); -}; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Card/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.scss similarity index 88% rename from packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Card/index.scss rename to packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.scss index 1363ab9a96d..56aa28f492f 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Card/index.scss +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.scss @@ -1,4 +1,4 @@ -@import '../../../../../../styles/shared'; +@import '../../../../../styles/shared'; .ThreadCard { cursor: pointer; @@ -25,7 +25,7 @@ } &:hover { - background: $neutral-100; + background: $neutral-50; text-decoration: none; color: unset; } @@ -56,7 +56,6 @@ } } - .content-body-wrapper { display: flex; flex-direction: column; @@ -86,17 +85,6 @@ display: flex; justify-content: space-between; - .comments-count { - align-items: center; - display: flex; - gap: 8px; - - .Icon, - .Text { - // color: $neutral-500; - } - } - .content-footer-btn { outline: 0; border: 0; @@ -119,4 +107,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Card/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx similarity index 81% rename from packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Card/index.tsx rename to packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx index bf8a911163e..84bdf04531d 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Card/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx @@ -12,20 +12,21 @@ import React, { useEffect } from 'react'; import { Link } from 'react-router-dom'; import { slugify } from 'utils'; import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; -import useBrowserWindow from '../../../../../hooks/useBrowserWindow'; -import AddressInfo from '../../../../../models/AddressInfo'; -import { ThreadStage } from '../../../../../models/types'; -import Permissions from '../../../../../utils/Permissions'; -import { CWTag } from '../../../../components/component_kit/cw_tag'; -import { CWText } from '../../../../components/component_kit/cw_text'; -import { getClasses } from '../../../../components/component_kit/helpers'; -import { isNewThread } from '../../NewThreadTag'; -import { isHot } from '../../helpers'; -import { AuthorAndPublishInfo } from '../AuthorAndPublishInfo'; -import { Options } from '../Options'; -import { AdminActionsProps } from '../Options/AdminActions'; -import { ReactionButton } from '../Options/ReactionButton'; -import './index.scss'; +import useBrowserWindow from '../../../../hooks/useBrowserWindow'; +import AddressInfo from '../../../../models/AddressInfo'; +import { ThreadStage } from '../../../../models/types'; +import Permissions from '../../../../utils/Permissions'; +import { CWTag } from 'views/components/component_kit/cw_tag'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { getClasses } from 'views/components/component_kit/helpers'; +import { isNewThread } from '../NewThreadTag'; +import { isHot } from '../helpers'; +import { AuthorAndPublishInfo } from './AuthorAndPublishInfo'; +import { ThreadOptions } from './ThreadOptions'; +import { AdminActionsProps } from './ThreadOptions/AdminActions'; +import { ReactionButton } from './ThreadOptions/ReactionButton'; +import './ThreadCard.scss'; +import useUserActiveAccount from 'hooks/useUserActiveAccount'; type CardProps = AdminActionsProps & { onBodyClick?: () => any; @@ -33,7 +34,7 @@ type CardProps = AdminActionsProps & { threadHref: string; }; -export const Card = ({ +export const ThreadCard = ({ thread, onDelete, onSpamToggle, @@ -53,6 +54,7 @@ export const Card = ({ }: CardProps) => { const { isLoggedIn } = useUserLoggedIn(); const { isWindowSmallInclusive } = useBrowserWindow({}); + const { activeAccount: hasJoinedCommunity } = useUserActiveAccount(); useEffect(() => { if (localStorage.getItem('dark-mode-state') === 'on') { @@ -91,7 +93,11 @@ export const Card = ({ key={thread.id} > {!isWindowSmallInclusive && ( - + )}
@@ -138,10 +144,11 @@ export const Card = ({ label={`${chainEntityTypeToProposalShortName( 'proposal' as IChainEntityKind )} - ${Number.isNaN(parseInt(link.identifier, 10)) - ? '' - : ` #${link.identifier}` - }`} + ${ + Number.isNaN(parseInt(link.identifier, 10)) + ? '' + : ` #${link.identifier}` + }`} /> ))}
@@ -171,12 +178,12 @@ export const Card = ({
- ( - + renderTrigger={(onClick) => ( + )} /> diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/AdminActions/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/AdminActions/index.tsx new file mode 100644 index 00000000000..8b74330caa8 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/AdminActions/index.tsx @@ -0,0 +1,4 @@ +import { AdminActions } from './AdminActions'; +import type { AdminActionsProps } from './AdminActions'; + +export { AdminActions, AdminActionsProps }; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/ReactionButton/index.scss b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/ReactionButton/ReactionButton.scss similarity index 59% rename from packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/ReactionButton/index.scss rename to packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/ReactionButton/ReactionButton.scss index 2d1277add8b..6bccddd3c6c 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/ReactionButton/index.scss +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/ReactionButton/ReactionButton.scss @@ -49,51 +49,6 @@ &.has-reacted { color: $primary-500; } - } } } - -.CommentReactionButton { - outline: 0; - border: 0; - display: flex; - justify-content: center; - align-items: center; - gap: 4px; - cursor: pointer; - padding: 4px 8px; - border-radius: $border-radius-corners-wider; - color: $neutral-600; - font-weight: 400; - - &:hover { - background-color: $neutral-200; - } - - &.has-reacted { - color: $primary-500; - } - - .Text { - color: $neutral-600; - } - - &.disabled { - pointer-events: none; - } - - .menu-buttons-text.Text { - color: $neutral-600; - font-weight: 400; - - &.has-reacted { - color: $primary-500; - } - } -} - -.reaction-button-tooltip-contents { - display: flex; - flex-direction: column; -} \ No newline at end of file diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/ReactionButton/index.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/ReactionButton/ReactionButton.tsx similarity index 50% rename from packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/ReactionButton/index.tsx rename to packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/ReactionButton/ReactionButton.tsx index 5fe433bcfa6..41b5d31073c 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/Options/ReactionButton/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadOptions/ReactionButton/ReactionButton.tsx @@ -1,98 +1,64 @@ import React, { useState } from 'react'; import app from 'state'; import type Thread from '../../../../../../models/Thread'; -import { CWIcon } from '../../../../../components/component_kit/cw_icons/cw_icon'; -import { Modal } from '../../../../../components/component_kit/cw_modal'; -import { CWTooltip } from '../../../../../components/component_kit/cw_popover/cw_tooltip'; -import { CWText } from '../../../../../components/component_kit/cw_text'; -import { - getClasses, - isWindowMediumSmallInclusive, -} from '../../../../../components/component_kit/helpers'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import { Modal } from 'views/components/component_kit/cw_modal'; +import { CWTooltip } from 'views/components/component_kit/cw_popover/cw_tooltip'; +import { isWindowMediumSmallInclusive } from 'views/components/component_kit/helpers'; import { getDisplayedReactorsForPopup, onReactionClick, -} from '../../../../../components/ReactionButton/helpers'; +} from 'views/components/ReactionButton/helpers'; import { LoginModal } from '../../../../../modals/login_modal'; -import './index.scss'; +import './ReactionButton.scss'; import { useReactionButton } from './useReactionButton'; +import CWUpvoteSmall from 'views/components/component_kit/new_designs/CWUpvoteSmall'; type ReactionButtonProps = { thread: Thread; size: 'small' | 'big'; + disabled: boolean; }; -export const ReactionButton = ({ thread, size }: ReactionButtonProps) => { +export const ReactionButton = ({ + thread, + size, + disabled, +}: ReactionButtonProps) => { const [isModalOpen, setIsModalOpen] = useState(false); const [reactors, setReactors] = useState>([]); const { dislike, hasReacted, isLoading, isUserForbidden, like } = useReactionButton(thread, setReactors); + const handleSmallVoteClick = async (e) => { + e.stopPropagation(); + e.preventDefault(); + if (!app.isLoggedIn() || !app.user.activeAccount) { + setIsModalOpen(true); + } else { + onReactionClick(e, hasReacted, dislike, like); + } + }; + const handleSmallVoteMouseEnter = async () => { + if (reactors.length === 0) { + setReactors(thread.associatedReactions.map((addr) => addr)); + } + }; + return ( <> {size === 'small' ? ( -
- } - renderTrigger={(handleInteraction) => ( - - {reactors.length} - - )} - /> - ) : ( - - {reactors.length} - - )} - + ) : (