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 && (