diff --git a/src/js/common/components/Position/PositionForBallotItem.jsx b/src/js/common/components/Position/PositionForBallotItem.jsx index fcbd4ee77..89cf2810c 100644 --- a/src/js/common/components/Position/PositionForBallotItem.jsx +++ b/src/js/common/components/Position/PositionForBallotItem.jsx @@ -7,20 +7,22 @@ import { Avatar, Typography } from '@mui/material'; import { withStyles } from '@mui/styles'; import HeartFavoriteToggleLoader from '../Widgets/HeartFavoriteToggle/HeartFavoriteToggleLoader'; import ThumbsUpDownToggle from '../Widgets/ThumbsUpDownToggle/ThumbsUpDownToggle'; -import { timeFromDate } from '../../utils/dateFormat'; import DesignTokenColors from '../Style/DesignTokenColors'; -import { CompactSecondaryText, CompactStatementText, SpeakerInfoWrapper, SpeakerName, SpeakerStatement, SpeakerStatementWrapper } from '../Style/PositionDisplayStyles'; +import { CompactStatementText, SpeakerInfoWrapper, SpeakerName, SpeakerStatement, SpeakerStatementWrapper } from '../Style/PositionDisplayStyles'; import speakerDisplayNameToInitials from '../../utils/speakerDisplayNameToInitials'; import SpeakerEndorsedOrOpposedSnippet from './SpeakerEndorsedOrOpposedSnippet'; import AppObservableStore from '../../stores/AppObservableStore'; import stringContains from '../../utils/stringContains'; import lookupPageNameAndPageTypeDict from '../../../utils/lookupPageNameAndPageTypeDict'; +const EndorsementDetailModal = React.lazy(() => import(/* webpackChunkName: 'EndorsementDetailModal' */ '../../../components/Ballot/EndorsementDetailModal')); + const OpenExternalWebSite = React.lazy(() => import(/* webpackChunkName: 'OpenExternalWebSite' */ '../Widgets/OpenExternalWebSite')); const ReadMore = React.lazy(() => import(/* webpackChunkName: 'ReadMore' */ '../Widgets/ReadMore')); function PositionForBallotItem ({ classes, compactMode, linksOpenExternalWebsite, position }) { const [anchorEl, setAnchorEL] = useState(null); + const [detailModalOpen, setDetailModalOpen] = useState(false); const onDotButtonClick = (e) => { setAnchorEL(e.currentTarget); @@ -111,7 +113,19 @@ function PositionForBallotItem ({ classes, compactMode, linksOpenExternalWebsite )} {statementText && compactMode && ( - {statementText} + setDetailModalOpen(true)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDetailModalOpen(true); + } + }} + role="button" + tabIndex={0} + > + {statementText} + )} {statementText && !compactMode && ( @@ -126,13 +140,14 @@ function PositionForBallotItem ({ classes, compactMode, linksOpenExternalWebsite )} {compactMode && ( - - {speakerDisplayName} - {position.is_support && {' endorsed '}} - {position.is_oppose && {' opposed '}} - {!position.is_support && !position.is_oppose && ' commented '} - {timeFromDate(position.last_updated || position.date_entered)} - + + {speakerDisplayName} + {(campaignXWeVoteId || organizationWeVoteId) && ( + + + + )} + )} {!compactMode && ( @@ -195,6 +210,15 @@ function PositionForBallotItem ({ classes, compactMode, linksOpenExternalWebsite )} + {compactMode && ( + }> + setDetailModalOpen(false)} + position={position} + /> + + )} ); } @@ -213,17 +237,61 @@ const styles = () => ({ }, }); -const CompactStanceText = styled('span', { - shouldForwardProp: (prop) => !['$support', '$oppose'].includes(prop), -})` - color: ${({ $support, $oppose }) => { - if ($support) return DesignTokenColors.confirmation700; - if ($oppose) return DesignTokenColors.alert700; - return 'inherit'; - }}; +const CompactHeartWrapper = styled('div')` + align-items: center; + display: flex; + flex-shrink: 0; + margin-left: 8px; + /* Strip the pill chrome on HeartFavoriteToggleContainer (3 wrappers deep: + Loader's container > Live's container > Base's pill container). */ + & > div > div > div { + background: transparent; + border: none; + border-radius: 0; + height: auto; + padding: 0; + } + /* Center the icon and the count text on the same baseline inside each toggle button. */ + & button { + align-items: center; + } + & svg { + height: 20px; + width: 20px; + } + & span { + font-size: 14px; + line-height: 1; + } +`; + +const CompactSpeakerName = styled('div')` + color: ${DesignTokenColors.neutralUI900}; + flex: 1; + font-size: 13px; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const CompactSpeakerRow = styled('div')` + align-items: center; + display: flex; + margin-top: 4px; `; -const CompactTimestamp = CompactSecondaryText; +const CompactStatementClickable = styled('div')` + cursor: pointer; + &:hover { + text-decoration: underline; + } + &:focus-visible { + outline: 2px solid ${DesignTokenColors.primary500}; + border-radius: 4px; + } +`; const FlexDiv = styled('div')` display: flex; @@ -264,6 +332,12 @@ const PositionForBallotItemWrapper = styled('div', { } ${({ $compactMode }) => $compactMode && ` + /* Let SpeakerInfoWrapper take the remaining row width AND allow it to shrink, + so the speaker name's ellipsis can kick in and the heart toggle stays in view. */ + ${SpeakerInfoWrapper} { + flex: 1; + min-width: 0; + } h3 { font-size: 14px; font-weight: 600; diff --git a/src/js/common/components/Position/SpeakerEndorsedOrOpposedSnippet.jsx b/src/js/common/components/Position/SpeakerEndorsedOrOpposedSnippet.jsx index e13b42cee..af5d6c32d 100644 --- a/src/js/common/components/Position/SpeakerEndorsedOrOpposedSnippet.jsx +++ b/src/js/common/components/Position/SpeakerEndorsedOrOpposedSnippet.jsx @@ -118,6 +118,7 @@ const CheckOutlinedStyled = styled(CheckOutlined)` `; const SpeakerPosition = styled('div')` + align-items: center; display: flex; `; diff --git a/src/js/common/components/Style/PositionDisplayStyles.jsx b/src/js/common/components/Style/PositionDisplayStyles.jsx index 787e11bdd..f075254ed 100644 --- a/src/js/common/components/Style/PositionDisplayStyles.jsx +++ b/src/js/common/components/Style/PositionDisplayStyles.jsx @@ -31,7 +31,7 @@ export const PositionText = styled('div')` export const SpeakerInfoWrapper = styled('div')` display: flex; - margin-bottom: 12px; + margin-bottom: 6px; margin-left: 15px; flex-direction: column; // width: 500px; diff --git a/src/js/common/stores/AppObservableStore.js b/src/js/common/stores/AppObservableStore.js index 357bfa94b..f3f8bcd22 100644 --- a/src/js/common/stores/AppObservableStore.js +++ b/src/js/common/stores/AppObservableStore.js @@ -71,6 +71,7 @@ const nonFluxState = { showEditPositionModal: false, editPositionModalPoliticianWeVoteId: '', editPositionModalSetPublic: false, + editPositionModalVisibilityOnly: false, showClaimProfileWithOtherWaysModal: false, functionToCallAfterProfileComplete: null, showCompleteYourProfileModal: false, @@ -219,6 +220,10 @@ export default { return nonFluxState.editPositionModalSetPublic; }, + getEditPositionModalVisibilityOnly () { + return nonFluxState.editPositionModalVisibilityOnly; + }, + getShowClaimProfileWithOtherWaysModal () { return nonFluxState.showClaimProfileWithOtherWaysModal; }, @@ -527,10 +532,15 @@ export default { nonFluxState.editPositionModalSetPublic = value; }, - setShowEditPositionModal (show, politicianWeVoteId = '', setPublic = false) { + setEditPositionModalVisibilityOnly (value) { + nonFluxState.editPositionModalVisibilityOnly = value; + }, + + setShowEditPositionModal (show, politicianWeVoteId = '', setPublic = false, visibilityOnly = false) { nonFluxState.showEditPositionModal = show; nonFluxState.editPositionModalPoliticianWeVoteId = show ? politicianWeVoteId : ''; nonFluxState.editPositionModalSetPublic = show ? setPublic : false; + nonFluxState.editPositionModalVisibilityOnly = show ? visibilityOnly : false; messageService.sendMessage('state updated showEditPositionModal'); }, diff --git a/src/js/components/Ballot/CandidateOpinionsColumn.jsx b/src/js/components/Ballot/CandidateOpinionsColumn.jsx index e4cabb2d1..695c0fd9e 100644 --- a/src/js/components/Ballot/CandidateOpinionsColumn.jsx +++ b/src/js/components/Ballot/CandidateOpinionsColumn.jsx @@ -5,7 +5,9 @@ import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; import { renderLog } from '../../common/utils/logging'; import AppObservableStore from '../../common/stores/AppObservableStore'; import CandidateStore from '../../stores/CandidateStore'; +import OrganizationStore from '../../stores/OrganizationStore'; import VoterStore from '../../stores/VoterStore'; +import { getPositionFollowersCount } from './opinionsHelpers'; import PositionList from './PositionList'; const VoterPositionEntryAndDisplay = React.lazy(() => import(/* webpackChunkName: 'VoterPositionEntryAndDisplay' */ '../PositionItem/VoterPositionEntryAndDisplay')); @@ -23,22 +25,27 @@ class CandidateOpinionsColumn extends Component { componentDidMount () { this.candidateStoreListener = CandidateStore.addListener(this.onStoreChange.bind(this)); + this.organizationStoreListener = OrganizationStore.addListener(this.onStoreChange.bind(this)); this.onStoreChange(); } componentWillUnmount () { this.candidateStoreListener.remove(); + this.organizationStoreListener.remove(); } onStoreChange () { const { candidateWeVoteId } = this.props; const allPositions = CandidateStore.getAllCachedPositionsByCandidateWeVoteId(candidateWeVoteId); const currentVoterWeVoteId = VoterStore.getLinkedOrganizationWeVoteId(); - const opinions = allPositions.filter( - (position) => position.statement_text && position.statement_text.length > 0 && - !(position.speaker_display_name && position.speaker_display_name.startsWith('Voter-')) && - position.speaker_we_vote_id !== currentVoterWeVoteId, - ); + const opinions = allPositions + .filter( + (position) => position.is_support && + position.statement_text && position.statement_text.length > 0 && + !(position.speaker_display_name && position.speaker_display_name.startsWith('Voter-')) && + position.speaker_we_vote_id !== currentVoterWeVoteId, + ) + .sort((a, b) => getPositionFollowersCount(b) - getPositionFollowersCount(a)); this.setState({ opinions, opinionsCount: opinions.length, diff --git a/src/js/components/Ballot/EndorsementDetailModal.jsx b/src/js/components/Ballot/EndorsementDetailModal.jsx new file mode 100644 index 000000000..479becf60 --- /dev/null +++ b/src/js/components/Ballot/EndorsementDetailModal.jsx @@ -0,0 +1,267 @@ +import { Launch, MoreHoriz } from '@mui/icons-material'; +import { Avatar, Popover, Typography } from '@mui/material'; +import PropTypes from 'prop-types'; +import React, { Suspense, useState } from 'react'; +import styled, { createGlobalStyle } from 'styled-components'; +import HeartFavoriteToggleLoader from '../../common/components/Widgets/HeartFavoriteToggle/HeartFavoriteToggleLoader'; +import SpeakerEndorsedOrOpposedSnippet from '../../common/components/Position/SpeakerEndorsedOrOpposedSnippet'; +import { SpeakerInfoWrapper, SpeakerName, SpeakerStatementWrapper } from '../../common/components/Style/PositionDisplayStyles'; +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; +import speakerDisplayNameToInitials from '../../common/utils/speakerDisplayNameToInitials'; +import lookupPageNameAndPageTypeDict from '../../utils/lookupPageNameAndPageTypeDict'; +import ModalDisplayTemplateB from '../Widgets/ModalDisplayTemplateB'; +import { resolveOrganizationWeVoteId } from './opinionsHelpers'; + +const MODAL_ID = 'endorsementDetail'; + +const OpenExternalWebSite = React.lazy(() => import(/* webpackChunkName: 'OpenExternalWebSite' */ '../../common/components/Widgets/OpenExternalWebSite')); + +export default function EndorsementDetailModal ({ isOpen, onClose, position }) { + const [anchorEl, setAnchorEl] = useState(null); + + if (!position) return null; + + const { + more_info_url: moreInfoUrl, + statement_text: statementText, + speaker_display_name: speakerDisplayName, + speaker_image_url_https_medium: speakerImageMedium, + } = position; + const organizationWeVoteId = resolveOrganizationWeVoteId(position); + + const { sx, children: avatarInitials } = speakerDisplayNameToInitials(speakerDisplayName); + const open = Boolean(anchorEl); + + const content = ( + + + {speakerImageMedium ? ( + + ) : ( + {avatarInitials} + )} + + + + {speakerDisplayName} + {organizationWeVoteId && ( + + + + )} + + {statementText && ( + + {statementText} + + )} + + + {moreInfoUrl && ( + <> + setAnchorEl(e.currentTarget)} + style={{ background: anchorEl ? DesignTokenColors.neutral100 : 'transparent' }} + type="button" + > + + + setAnchorEl(null)} + open={open} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + }> + + + View source of opinion + {' '} + + + + )} + destinationPageName={lookupPageNameAndPageTypeDict(moreInfoUrl).pageName} + destinationPageType="endorserWebsite" + linkIdAttribute="viewSourceOfPosition" + target="_blank" + trackingOn + url={moreInfoUrl} + /> + + + + )} + + + + ); + + const buttonRow = ( + + + Close + + + ); + + return ( + <> + + + + + {content} + {buttonRow} + + )} + toggleModal={onClose} + /> + + ); +} + +EndorsementDetailModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + position: PropTypes.object, +}; + +const HideHeader = createGlobalStyle` + /* Force templateB's DialogTitle to occupy zero vertical space, but keep it + in the DOM so its built-in X close button stays anchored at the top of + the modal (outside the scrollable content area). The X is moved to + position: absolute against the dialog paper, so it survives the collapse. */ + .MuiDialogTitle-root:has(#closeModalDisplayTemplateB${MODAL_ID}) { + height: 0 !important; + min-height: 0 !important; + overflow: visible !important; + padding: 0 !important; + } + .MuiDialogTitle-root:has(#closeModalDisplayTemplateB${MODAL_ID}) > div { + min-height: 0 !important; + } + .MuiDialogTitle-root:has(#closeModalDisplayTemplateB${MODAL_ID}) > hr { + display: none !important; + } + /* Pin the X near the corner of the modal paper, with a small inset. */ + #closeModalDisplayTemplateB${MODAL_ID} { + position: absolute !important; + top: 16px !important; + right: 16px !important; + } +`; + +const SoftenCorners = createGlobalStyle` + .MuiDialog-paper:has(#closeModalDisplayTemplateB${MODAL_ID}) { + border-radius: 20px !important; + /* Constrain to viewport and cancel templateB's default top/transform offset + (top: 50px + translate(0, -20%)) which can push the top off-screen on + shorter viewports once max-height is large. */ + max-height: 90vh !important; + margin: 16px auto !important; + top: auto !important; + transform: none !important; + } +`; + +const ScrollableContent = createGlobalStyle` + .MuiDialog-paper:has(#closeModalDisplayTemplateB${MODAL_ID}) .MuiDialogContent-root { + /* Zero all padding so ContentWrapper can place its own X/Close right up against the modal edge. */ + padding: 0 !important; + overflow-y: auto !important; + flex: 1 1 auto !important; + } +`; + +const ButtonRow = styled('div')` + display: flex; + justify-content: flex-end; + margin-top: 16px; + padding: 0 24px 24px 0; +`; + +const CloseButton = styled('button')` + background: ${DesignTokenColors.primary700}; + border: none; + border-radius: 50px; + color: white; + cursor: pointer; + padding: 8px 28px; + &:hover { + background: ${DesignTokenColors.primary800}; + } +`; + +const ContentWrapper = styled('div')` + display: flex; + padding: 0 48px 4px 24px; +`; + +const HeartFavoriteToggleWrapper = styled('div')` + margin-top: -5px; + margin-left: 5px; +`; + +const LaunchStyled = styled(Launch)` + height: 14px; + margin-left: 2px; + margin-top: -3px; + width: 14px; +`; + +const MoreHorizStyled = styled(MoreHoriz)` + color: ${DesignTokenColors.neutral400}; + font-size: 30px; +`; + +const OpinionSource = styled('button')` + background: transparent; + border: none; +`; + +const SourceButton = styled('button')` + background: transparent; + border: none; + border-radius: 30px; + cursor: pointer; + height: 34px; + width: 34px; +`; + +const SpeakerImage = styled('img')` + border-radius: 42px; + height: 42px; + min-width: 42px; + width: 42px; +`; + +const SpeakerImageWrapper = styled('div')` + width: 42px; +`; + +const SpeakerInfoNameFavoritesWrapper = styled('div')` + align-items: center; + display: flex; +`; + +const SpeakerPositionLikesSourceWrapper = styled('div')` + display: flex; + justify-content: space-between; + margin-top: 4px; +`; + +const StatementWithNewlines = styled('div')` + color: ${DesignTokenColors.neutral900}; + margin-bottom: 5px; + white-space: pre-wrap; +`; diff --git a/src/js/components/Ballot/MeasureInfoModal.jsx b/src/js/components/Ballot/MeasureInfoModal.jsx index f044377f7..91e60e1dd 100644 --- a/src/js/components/Ballot/MeasureInfoModal.jsx +++ b/src/js/components/Ballot/MeasureInfoModal.jsx @@ -32,6 +32,12 @@ export default function MeasureInfoModal ({ const tabContentJSX = [ // Tab 0 — Description + {!!(measureTitle) && ( + {measureTitle} + )} + {!!(measureSubtitle) && ( + {measureSubtitle} + )} {!!(measureUrl) && ( import(/* webpackChunkName: 'VoterPositionEntryAndDisplay' */ '../PositionItem/VoterPositionEntryAndDisplay')); @@ -23,22 +25,27 @@ class MeasureOpinionsColumn extends Component { componentDidMount () { this.measureStoreListener = MeasureStore.addListener(this.onStoreChange.bind(this)); + this.organizationStoreListener = OrganizationStore.addListener(this.onStoreChange.bind(this)); this.onStoreChange(); } componentWillUnmount () { this.measureStoreListener.remove(); + this.organizationStoreListener.remove(); } onStoreChange () { const { measureWeVoteId } = this.props; const allPositions = MeasureStore.getAllCachedPositionsByMeasureWeVoteId(measureWeVoteId); const currentVoterWeVoteId = VoterStore.getLinkedOrganizationWeVoteId(); - const opinions = allPositions.filter( - (position) => position.statement_text && position.statement_text.length > 0 && - !(position.speaker_display_name && position.speaker_display_name.startsWith('Voter-')) && - position.speaker_we_vote_id !== currentVoterWeVoteId, - ); + const opinions = allPositions + .filter( + (position) => position.is_support && + position.statement_text && position.statement_text.length > 0 && + !(position.speaker_display_name && position.speaker_display_name.startsWith('Voter-')) && + position.speaker_we_vote_id !== currentVoterWeVoteId, + ) + .sort((a, b) => getPositionFollowersCount(b) - getPositionFollowersCount(a)); this.setState({ opinions, opinionsCount: opinions.length, diff --git a/src/js/components/Ballot/PositionList.jsx b/src/js/components/Ballot/PositionList.jsx index 0680168c0..8288910a3 100644 --- a/src/js/components/Ballot/PositionList.jsx +++ b/src/js/components/Ballot/PositionList.jsx @@ -419,17 +419,18 @@ class PositionList extends Component { })} {compactMode && maxToShow && filteredPositionListLength > maxToShow && this.props.onSeeMoreClick && ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.props.onSeeMoreClick(); - }}} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.props.onSeeMoreClick(); + } + }} > - See more + See more opinions )} {!compactMode && ( @@ -472,7 +473,7 @@ const CompactSeeMoreLink = styled('button')` background: none; border: 0; padding: 0; - margin-left: 5px; + margin-left: 57px; color: #1073d4; cursor: pointer; display: inline-flex; diff --git a/src/js/components/Ballot/opinionsHelpers.js b/src/js/components/Ballot/opinionsHelpers.js new file mode 100644 index 000000000..cc53cbdb5 --- /dev/null +++ b/src/js/components/Ballot/opinionsHelpers.js @@ -0,0 +1,19 @@ +import stringContains from '../../common/utils/stringContains'; +import OrganizationStore from '../../stores/OrganizationStore'; + +// Resolve the organization weVoteId for a position, falling back to speaker_we_vote_id +// when it has the 'org' shape (some positions only carry the speaker id). +export function resolveOrganizationWeVoteId (position) { + if (!position) return ''; + const orgId = position.organization_we_vote_id; + if (orgId) return orgId; + const speakerId = position.speaker_we_vote_id; + if (speakerId && stringContains('org', speakerId)) return speakerId; + return ''; +} + +// Returns the supporters/followers count for the org that authored the position. +export function getPositionFollowersCount (position) { + const orgId = resolveOrganizationWeVoteId(position); + return orgId ? OrganizationStore.getOrganizationFollowersCount(orgId) : 0; +} diff --git a/src/js/components/PositionItem/VoterPositionEntryAndDisplay.jsx b/src/js/components/PositionItem/VoterPositionEntryAndDisplay.jsx index 8dcf53280..dddda83e2 100644 --- a/src/js/components/PositionItem/VoterPositionEntryAndDisplay.jsx +++ b/src/js/components/PositionItem/VoterPositionEntryAndDisplay.jsx @@ -16,6 +16,7 @@ import VoterPositionEditTripleDot from '../../common/components/Position/VoterPo import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; import { CompactSecondaryText, CompactStatementText, SpeakerName, SpeakerStatement, SpeakerStatementWrapper } from '../../common/components/Style/PositionDisplayStyles'; import AppObservableStore, { messageService } from '../../common/stores/AppObservableStore'; +import CandidateStore from '../../stores/CandidateStore'; import MeasureStore from '../../stores/MeasureStore'; import PoliticianStore from '../../common/stores/PoliticianStore'; import { prepareForCordovaKeyboard, restoreStylesAfterCordovaKeyboard } from '../../common/utils/cordovaUtils'; @@ -181,7 +182,6 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP const effectivePoliticianWeVoteId = isMeasure ? '' : politicianWeVoteId; const effectiveWeVoteIdRef = useRef(effectiveWeVoteId); - const { allCachedPoliticians } = PoliticianStore.getState(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [ballotItemName, setBallotItemName] = useState(''); @@ -197,6 +197,7 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP const [draftSelectedStance, setDraftSelectedStance] = useState('SUPPORT'); const [supportOrOpposeStanceExists, setSupportOrOpposeStanceExists] = useState(false); const [setAsDefaultVisibility, setSetAsDefaultVisibility] = useState(false); + const [visibilityOnly, setVisibilityOnly] = useState(false); const [visibilitySetting, setVisibilitySetting] = useState('friends'); // 'public', 'friends', 'private' const [voterFirstName, setVoterFirstName] = useState(''); const [voterLastName, setVoterLastName] = useState(''); @@ -281,6 +282,7 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP setTimeout(() => setVisibilitySetting('public'), 300); AppObservableStore.setEditPositionModalSetPublic(false); } + setVisibilityOnly(AppObservableStore.getEditPositionModalVisibilityOnly()); setShowEditModal(true); AppObservableStore.setShowEditPositionModal(false); } @@ -347,17 +349,30 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP setVoterName(voter.full_name || 'Anonymous'); }; - useEffect(() => { - if (isMeasure && effectiveWeVoteId) { - const measure = MeasureStore.getMeasure(effectiveWeVoteId); + const refreshBallotItemName = useCallback(() => { + if (!ballotItemWeVoteIdProp) return; + if (isMeasure) { + const measure = MeasureStore.getMeasure(ballotItemWeVoteIdProp); if (measure && measure.ballot_item_display_name) { setBallotItemName(measure.ballot_item_display_name); } - } else if (politicianWeVoteId && allCachedPoliticians && allCachedPoliticians[politicianWeVoteId]) { - const { politician_name: ballotItemNameNew } = allCachedPoliticians[politicianWeVoteId]; - setBallotItemName(ballotItemNameNew); + } else { + const candidate = CandidateStore.getCandidateByWeVoteId(ballotItemWeVoteIdProp); + if (candidate && candidate.ballot_item_display_name) { + setBallotItemName(candidate.ballot_item_display_name); + } } - }, [isMeasure, effectiveWeVoteId, politicianWeVoteId, allCachedPoliticians]); + }, [ballotItemWeVoteIdProp, isMeasure]); + + useEffect(() => { + refreshBallotItemName(); + const measureStoreListener = MeasureStore.addListener(refreshBallotItemName); + const candidateStoreListener = CandidateStore.addListener(refreshBallotItemName); + return () => { + measureStoreListener.remove(); + candidateStoreListener.remove(); + }; + }, [refreshBallotItemName]); useEffect(() => { effectiveWeVoteIdRef.current = effectiveWeVoteId; @@ -404,6 +419,7 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP useEffect(() => { if (openEditModalOnLoad) { + setVisibilityOnly(false); setShowEditModal(true); } }, [openEditModalOnLoad]); @@ -488,7 +504,18 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP renderLog('VoterPositionEntryAndDisplay'); // Set LOG_RENDER_EVENTS to log all renders - const editPositionModalTitleText = positionExists ? `Edit opinion${ballotItemName && ` about ${ballotItemName}`}` : `Create opinion${ballotItemName && ` about ${ballotItemName}`}`; + let editPositionModalTitleText; + let saveButtonText; + if (visibilityOnly) { + editPositionModalTitleText = `Update visibility${ballotItemName && ` for ${ballotItemName}`}`; + saveButtonText = 'Update visibility'; + } else if (positionExists) { + editPositionModalTitleText = `Edit opinion${ballotItemName && ` about ${ballotItemName}`}`; + saveButtonText = 'Save opinion'; + } else { + editPositionModalTitleText = `Create opinion${ballotItemName && ` about ${ballotItemName}`}`; + saveButtonText = 'Add opinion'; + } const deleteConfirmationModalTitleText = ballotItemName ? `Delete opinion about ${ballotItemName}?` : 'Delete opinion?'; const statementPlaceholderText = 'What\'s on your mind?'; const rowsToShow = isAndroid() ? 3 : 4; @@ -514,54 +541,58 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP - {/* Your opinion text area */} - Your opinion: - - - - - {/* Choose / Oppose / Undecided toggle buttons */} - - {isMeasure ? 'What is your position?' : 'Will you choose this candidate?'} - - - setDraftSelectedStance('SUPPORT')} - > - - {isMeasure ? 'Vote Yes' : 'Choose'} - - setDraftSelectedStance('OPPOSE')} - > - - {isMeasure ? 'Vote No' : 'Oppose'} - - setDraftSelectedStance('INFO_ONLY')} - > - Undecided - - + {!visibilityOnly && ( + <> + {/* Your opinion text area */} + Your opinion: + + + + + {/* Choose / Oppose / Undecided toggle buttons */} + + {isMeasure ? 'What is your position?' : 'Will you choose this candidate?'} + + + setDraftSelectedStance('SUPPORT')} + > + + {isMeasure ? 'Vote Yes' : 'Choose'} + + setDraftSelectedStance('OPPOSE')} + > + + {isMeasure ? 'Vote No' : 'Oppose'} + + setDraftSelectedStance('INFO_ONLY')} + > + Undecided + + + + )} {/* Visibility toggle: Public / WeVote friends / Private */} @@ -627,7 +658,7 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP {/* Action buttons */} - {positionExists && ( + {(positionExists || visibilityOnly) && ( toggleEditModalLocal()} @@ -642,7 +673,7 @@ function VoterPositionEntryAndDisplay ({ ballotItemWeVoteId: ballotItemWeVoteIdP classes={{ root: classes.saveButtonRoot }} type="submit" > - {positionExists ? 'Save opinion' : 'Add opinion'} + {saveButtonText} diff --git a/src/js/components/Widgets/ItemActionBar/ItemActionBar.jsx b/src/js/components/Widgets/ItemActionBar/ItemActionBar.jsx index 3804ab46b..f2b9bb505 100644 --- a/src/js/components/Widgets/ItemActionBar/ItemActionBar.jsx +++ b/src/js/components/Widgets/ItemActionBar/ItemActionBar.jsx @@ -383,7 +383,10 @@ class ItemActionBar extends PureComponent { }; onClickDotsMenu = () => { - AppObservableStore.setShowEditPositionModal(true, this.props.politicianWeVoteId); + // Dots open the modal in visibility-only mode for both measures and candidates. + const { ballotItemType, ballotItemWeVoteId } = this.state; + const idForModal = ballotItemType === 'MEASURE' ? ballotItemWeVoteId : this.props.politicianWeVoteId; + AppObservableStore.setShowEditPositionModal(true, idForModal, false, true); }; helpThemWinButton = (localUniqueId) => { @@ -1068,26 +1071,25 @@ class ItemActionBar extends PureComponent { {/* Chat bubble + visibility row for measures. - When voter has typed an opinion, replace bubble with divider + visibility text. */} + Chat bubble always renders; when voter has typed an opinion, divider + visibility text appear beside it. */} {ballotItemType === 'MEASURE' && !this.props.commentButtonHide && !this.props.inModal && ( <> - {measureHasOpinion ? ( + + + + {measureHasOpinion && ( - ) : ( - - - )} {/* Mobile: full-width visibility row wraps below buttons */} @@ -1096,7 +1098,7 @@ class ItemActionBar extends PureComponent { )} @@ -1107,8 +1109,15 @@ class ItemActionBar extends PureComponent { Pass showCandidateStaffAndChat prop to enable (e.g. in BallotScrollingContainer). */} {((ballotItemType === 'CANDIDATE' || ballotItemType === 'POLITICIAN') && this.props.showCandidateStaffAndChat) && ( <> - {/* When visibilityRowOpen: hide bubble, show divider + visibility text inline */} - {visibilityRowOpen ? ( + {/* Chat bubble always renders; when visibilityRowOpen, divider + visibility text appear beside it. */} + + + + {visibilityRowOpen && ( - ) : ( - - - )} {!this.props.inModal && (