From 1a71bea7d7671e68f71e1359401f256e24b1f8c7 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 19:05:10 +0000 Subject: [PATCH 1/3] fix(newsletter): report the profile subscriber row through the summary card's height channel The profile summary CollapsibleCard measures its content once on first layout; the subscriber row appears only after the sender query resolves, so it grew the content past the measured height and was clipped under the tab bar. The row now has a fixed height and reports its visibility to ProfileSummaryView, which combines it with the VP/RC bars toggle into the ONE moreHeight value the card accepts (the channel is single-valued, so two direct writers would overwrite each other). The count text is single line and shrinks on narrow devices. Closes #3522 --- src/components/newsletterSenderInfo/index.ts | 1 + .../newsletterSenderInfo.tsx | 28 ++++++++++++--- .../view/profileSummaryView.tsx | 36 +++++++++++++++---- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/components/newsletterSenderInfo/index.ts b/src/components/newsletterSenderInfo/index.ts index e3ff8667d0..d251b61549 100644 --- a/src/components/newsletterSenderInfo/index.ts +++ b/src/components/newsletterSenderInfo/index.ts @@ -1 +1,2 @@ export { default as NewsletterSenderInfo } from './newsletterSenderInfo'; +export { NEWSLETTER_SENDER_INFO_HEIGHT } from './newsletterSenderInfo'; diff --git a/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx b/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx index 41b6b065ed..37a5bde6a8 100644 --- a/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx +++ b/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { useIntl } from 'react-intl'; import { useNavigation } from '@react-navigation/native'; @@ -11,8 +11,18 @@ import { useAppDispatch, useAuth } from '../../hooks'; import { toastNotification } from '../../redux/actions/uiAction'; import { IconButton } from '../iconButton'; +/** + * Fixed row height, exported so ProfileSummaryView can report it through the + * summary card's moreHeight channel: the CollapsibleCard measures its content + * ONCE, so anything that appears after a query resolves must announce the + * space it takes or it gets clipped (vision-mobile#3522). + */ +export const NEWSLETTER_SENDER_INFO_HEIGHT = 28; + interface Props { username: string; + /** Reports whether the row occupies space; stable identity expected. */ + onVisibilityChange?: (visible: boolean) => void; } /** @@ -22,7 +32,7 @@ interface Props { * view is gated to the list owner server-side, so this mounts only on the * own profile and stays silent while the lookup is unresolved or refused. */ -const NewsletterSenderInfo = ({ username }: Props) => { +const NewsletterSenderInfo = ({ username, onVisibilityChange }: Props) => { const intl = useIntl(); const dispatch = useAppDispatch(); const navigation = useNavigation(); @@ -33,6 +43,15 @@ const NewsletterSenderInfo = ({ username }: Props) => { ); const subscribers = senderQuery.data?.subscribers; + const visible = !!subscribers; + + useEffect(() => { + onVisibilityChange?.(visible); + return () => { + onVisibilityChange?.(false); + }; + }, [visible, onVisibilityChange]); + if (!subscribers) { return null; } @@ -44,7 +63,7 @@ const NewsletterSenderInfo = ({ username }: Props) => { return ( - + {intl.formatMessage( { id: 'newsletter.subscriber_count' }, { weekly: subscribers.weekly ?? 0, monthly: subscribers.monthly ?? 0 }, @@ -66,13 +85,14 @@ const NewsletterSenderInfo = ({ username }: Props) => { const styles = EStyleSheet.create({ row: { + height: NEWSLETTER_SENDER_INFO_HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, - paddingTop: 6, }, countText: { + flexShrink: 1, fontSize: 13, color: '$primaryDarkGray', }, diff --git a/src/components/profileSummary/view/profileSummaryView.tsx b/src/components/profileSummary/view/profileSummaryView.tsx index bcb7456f23..3c70b1ea62 100644 --- a/src/components/profileSummary/view/profileSummaryView.tsx +++ b/src/components/profileSummary/view/profileSummaryView.tsx @@ -24,7 +24,7 @@ import { makeCountFriendly } from '../../../utils/formatter'; import styles from './profileSummaryStyles'; import getWindowDimensions from '../../../utils/getWindowDimensions'; import { SheetNames } from '../../../navigation/sheets'; -import { NewsletterSenderInfo } from '../../newsletterSenderInfo'; +import { NEWSLETTER_SENDER_INFO_HEIGHT, NewsletterSenderInfo } from '../../newsletterSenderInfo'; const DEVICE_WIDTH = getWindowDimensions().width; @@ -33,9 +33,30 @@ class ProfileSummaryView extends PureComponent { super(props); this.state = { isShowPercentText: props.isShowPercentText, + senderInfoVisible: false, }; } + // The summary card's moreHeight channel is single-valued, so every consumer + // of extra height reports through this ONE combiner: the VP/RC bars toggle + // and the subscriber row would otherwise overwrite each other's height and + // the card would clip whichever reported first (vision-mobile#3522). + _reportMoreHeight = () => { + const { handleUIChange } = this.props; + const { isShowPercentText, senderInfoVisible } = this.state; + if (handleUIChange) { + handleUIChange( + (isShowPercentText ? 30 : 0) + (senderInfoVisible ? NEWSLETTER_SENDER_INFO_HEIGHT : 0), + ); + } + }; + + _handleSenderInfoVisibility = (visible: boolean) => { + if (this.state.senderInfoVisible !== visible) { + this.setState({ senderInfoVisible: visible }, this._reportMoreHeight); + } + }; + _handleOnPressLink = (url: any) => { if (url) { Linking.openURL(url); @@ -277,7 +298,7 @@ class ProfileSummaryView extends PureComponent { _renderBars = () => { const { isShowPercentText } = this.state; - const { handleUIChange, hoursRC, hoursVP, isDarkTheme, percentRC, percentVP } = this.props; + const { hoursRC, hoursVP, isDarkTheme, percentRC, percentVP } = this.props; const votingPowerHoursText = hoursVP && `• Full in ${hoursVP} hours`; const votingPowerText = `Voting power: ${percentVP}% ${votingPowerHoursText || ''}`; @@ -288,9 +309,7 @@ class ProfileSummaryView extends PureComponent { - this.setState({ isShowPercentText: !isShowPercentText }, () => { - handleUIChange(!isShowPercentText ? 30 : 0); - }) + this.setState({ isShowPercentText: !isShowPercentText }, this._reportMoreHeight) } > { {this._renderIdentity()} {this._renderMetadata()} {this._renderFollowerStats()} - {!!isOwnProfile && !!username && } + {!!isOwnProfile && !!username && ( + + )} {this._renderBars()} ); From c45ed639e34ff9c5aa25b176347c386333a41c46 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 19:14:19 +0000 Subject: [PATCH 2/3] fix(newsletter): move the subscriber row out of the measured card Review round: the fixed 28pt reservation was smaller than the row's own 30pt icon, fought accessibility font scaling and double-counted when cached data rendered the row before the card's one-shot measurement. The row now lives as a SIBLING below the CollapsibleCard in normal layout flow: natural height, no moreHeight arithmetic at all, hidden together with the collapsed summary. The summary view goes back to the plain bars toggle and the Manage label is single line with a max width so a long translation cannot push the actions off narrow screens. --- src/components/newsletterSenderInfo/index.ts | 1 - .../newsletterSenderInfo.tsx | 34 +++---- src/components/profile/profileView.tsx | 94 +++++++++++-------- .../view/profileSummaryView.tsx | 35 +------ 4 files changed, 68 insertions(+), 96 deletions(-) diff --git a/src/components/newsletterSenderInfo/index.ts b/src/components/newsletterSenderInfo/index.ts index d251b61549..e3ff8667d0 100644 --- a/src/components/newsletterSenderInfo/index.ts +++ b/src/components/newsletterSenderInfo/index.ts @@ -1,2 +1 @@ export { default as NewsletterSenderInfo } from './newsletterSenderInfo'; -export { NEWSLETTER_SENDER_INFO_HEIGHT } from './newsletterSenderInfo'; diff --git a/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx b/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx index 37a5bde6a8..9a0b0c2aaf 100644 --- a/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx +++ b/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { useIntl } from 'react-intl'; import { useNavigation } from '@react-navigation/native'; @@ -11,18 +11,8 @@ import { useAppDispatch, useAuth } from '../../hooks'; import { toastNotification } from '../../redux/actions/uiAction'; import { IconButton } from '../iconButton'; -/** - * Fixed row height, exported so ProfileSummaryView can report it through the - * summary card's moreHeight channel: the CollapsibleCard measures its content - * ONCE, so anything that appears after a query resolves must announce the - * space it takes or it gets clipped (vision-mobile#3522). - */ -export const NEWSLETTER_SENDER_INFO_HEIGHT = 28; - interface Props { username: string; - /** Reports whether the row occupies space; stable identity expected. */ - onVisibilityChange?: (visible: boolean) => void; } /** @@ -32,7 +22,7 @@ interface Props { * view is gated to the list owner server-side, so this mounts only on the * own profile and stays silent while the lookup is unresolved or refused. */ -const NewsletterSenderInfo = ({ username, onVisibilityChange }: Props) => { +const NewsletterSenderInfo = ({ username }: Props) => { const intl = useIntl(); const dispatch = useAppDispatch(); const navigation = useNavigation(); @@ -43,15 +33,6 @@ const NewsletterSenderInfo = ({ username, onVisibilityChange }: Props) => { ); const subscribers = senderQuery.data?.subscribers; - const visible = !!subscribers; - - useEffect(() => { - onVisibilityChange?.(visible); - return () => { - onVisibilityChange?.(false); - }; - }, [visible, onVisibilityChange]); - if (!subscribers) { return null; } @@ -77,19 +58,25 @@ const NewsletterSenderInfo = ({ username, onVisibilityChange }: Props) => { onPress={_handleCopyLink} /> navigation.navigate(ROUTES.SCREENS.EMAIL_DIGESTS)}> - {intl.formatMessage({ id: 'newsletter.manage' })} + + {intl.formatMessage({ id: 'newsletter.manage' })} + ); }; const styles = EStyleSheet.create({ + // Natural (unfixed) height ON PURPOSE: the row lives OUTSIDE the summary + // card's one-shot height measurement, so it may grow with accessibility + // font scaling and its 30pt icon without clipping anything + // (vision-mobile#3522). row: { - height: NEWSLETTER_SENDER_INFO_HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, + paddingBottom: 4, }, countText: { flexShrink: 1, @@ -100,6 +87,7 @@ const styles = EStyleSheet.create({ fontSize: 13, color: '$primaryBlue', marginLeft: 8, + maxWidth: 120, }, }); diff --git a/src/components/profile/profileView.tsx b/src/components/profile/profileView.tsx index 39db71b1a9..4d6682b724 100644 --- a/src/components/profile/profileView.tsx +++ b/src/components/profile/profileView.tsx @@ -10,6 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { CollapsibleCard } from '../collapsibleCard'; import { Header } from '../header'; import { ProfileSummaryPlaceHolder, WalletDetailsPlaceHolder } from '../basicUIElements'; +import { NewsletterSenderInfo } from '../newsletterSenderInfo'; import { ProfileSummary } from '../profileSummary'; import { Wallet } from '../wallet'; import { IconButton } from '../iconButton'; @@ -214,47 +215,58 @@ class ProfileView extends PureComponent { return !isReady ? ( ) : ( - } - isExpanded={isSummaryOpen} - handleOnExpanded={this._handleOnSummaryExpanded} - moreHeight={collapsibleMoreHeight} - fitContent - noBorder - > - - + <> + } + isExpanded={isSummaryOpen} + handleOnExpanded={this._handleOnSummaryExpanded} + moreHeight={collapsibleMoreHeight} + fitContent + noBorder + > + + + {/* Outside the CollapsibleCard ON PURPOSE: the card measures its content + ONCE, and this row appears only after the sender query resolves, so + inside it the row was clipped (and reserving a fixed height fought + font scaling and cached-data double counts). As a sibling it takes + natural height in normal flow; it follows the summary's collapse + state so a collapsed header hides it too. */} + {!!isOwnProfile && !!username && isSummaryOpen && ( + + )} + ); }; diff --git a/src/components/profileSummary/view/profileSummaryView.tsx b/src/components/profileSummary/view/profileSummaryView.tsx index 3c70b1ea62..70815c5796 100644 --- a/src/components/profileSummary/view/profileSummaryView.tsx +++ b/src/components/profileSummary/view/profileSummaryView.tsx @@ -24,7 +24,6 @@ import { makeCountFriendly } from '../../../utils/formatter'; import styles from './profileSummaryStyles'; import getWindowDimensions from '../../../utils/getWindowDimensions'; import { SheetNames } from '../../../navigation/sheets'; -import { NEWSLETTER_SENDER_INFO_HEIGHT, NewsletterSenderInfo } from '../../newsletterSenderInfo'; const DEVICE_WIDTH = getWindowDimensions().width; @@ -33,30 +32,9 @@ class ProfileSummaryView extends PureComponent { super(props); this.state = { isShowPercentText: props.isShowPercentText, - senderInfoVisible: false, }; } - // The summary card's moreHeight channel is single-valued, so every consumer - // of extra height reports through this ONE combiner: the VP/RC bars toggle - // and the subscriber row would otherwise overwrite each other's height and - // the card would clip whichever reported first (vision-mobile#3522). - _reportMoreHeight = () => { - const { handleUIChange } = this.props; - const { isShowPercentText, senderInfoVisible } = this.state; - if (handleUIChange) { - handleUIChange( - (isShowPercentText ? 30 : 0) + (senderInfoVisible ? NEWSLETTER_SENDER_INFO_HEIGHT : 0), - ); - } - }; - - _handleSenderInfoVisibility = (visible: boolean) => { - if (this.state.senderInfoVisible !== visible) { - this.setState({ senderInfoVisible: visible }, this._reportMoreHeight); - } - }; - _handleOnPressLink = (url: any) => { if (url) { Linking.openURL(url); @@ -298,7 +276,7 @@ class ProfileSummaryView extends PureComponent { _renderBars = () => { const { isShowPercentText } = this.state; - const { hoursRC, hoursVP, isDarkTheme, percentRC, percentVP } = this.props; + const { handleUIChange, hoursRC, hoursVP, isDarkTheme, percentRC, percentVP } = this.props; const votingPowerHoursText = hoursVP && `• Full in ${hoursVP} hours`; const votingPowerText = `Voting power: ${percentVP}% ${votingPowerHoursText || ''}`; @@ -309,7 +287,9 @@ class ProfileSummaryView extends PureComponent { - this.setState({ isShowPercentText: !isShowPercentText }, this._reportMoreHeight) + this.setState({ isShowPercentText: !isShowPercentText }, () => { + handleUIChange(!isShowPercentText ? 30 : 0); + }) } > { }; render() { - const { isOwnProfile, username } = this.props; return ( {this._renderCoverImage()} @@ -342,12 +321,6 @@ class ProfileSummaryView extends PureComponent { {this._renderIdentity()} {this._renderMetadata()} {this._renderFollowerStats()} - {!!isOwnProfile && !!username && ( - - )} {this._renderBars()} ); From e0d2d076a75abc8052fd078a0d450f5758c22936 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 19:57:23 +0000 Subject: [PATCH 3/3] feat(newsletter): surface the Email digest action on the profile row Mirrors the website's mobile layout, where Email digest is a first-class profile action instead of a menu entry: an envelope icon button sits next to Message (compact enough for narrow devices, accessibility-labeled) and the dropdown entry is removed so the action lives in one place. Closes #3524 --- .../view/profileSummaryStyles.ts | 3 +++ .../view/profileSummaryView.tsx | 26 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/profileSummary/view/profileSummaryStyles.ts b/src/components/profileSummary/view/profileSummaryStyles.ts index 1d62202091..2ea301b594 100644 --- a/src/components/profileSummary/view/profileSummaryStyles.ts +++ b/src/components/profileSummary/view/profileSummaryStyles.ts @@ -133,6 +133,9 @@ export default EStyleSheet.create({ fontSize: 13, color: '$primaryDarkText', }, + newsletterButton: { + marginLeft: 4, + }, messageButton: { borderColor: '$iconColor', borderWidth: 1, diff --git a/src/components/profileSummary/view/profileSummaryView.tsx b/src/components/profileSummary/view/profileSummaryView.tsx index 70815c5796..3a4d476853 100644 --- a/src/components/profileSummary/view/profileSummaryView.tsx +++ b/src/components/profileSummary/view/profileSummaryView.tsx @@ -14,6 +14,7 @@ import DARK_COVER_IMAGE from '../../../assets/dark_cover_image.png'; import { TextWithIcon } from '../../basicUIElements'; import { PercentBar } from '../../percentBar'; import { DropdownButton } from '../../dropdownButton'; +import { IconButton } from '../../iconButton'; import { UserAvatar } from '../../userAvatar'; import { ProBadge } from '../../proBadge'; @@ -41,6 +42,12 @@ class ProfileSummaryView extends PureComponent { } }; + _handleNewsletterPress = () => { + SheetManager.show(SheetNames.NEWSLETTER_DIGEST, { + payload: { type: 'creator', target: this.props.username }, + }); + }; + _handleOnDropdownSelect = (index: any) => { const { isMuted, @@ -72,13 +79,6 @@ class ProfileSummaryView extends PureComponent { handleReportUser(); } break; - case 4: - // Appended LAST on purpose: the dropdown dispatches by index, so a - // middle insertion would silently reroute the actions below it. - SheetManager.show(SheetNames.NEWSLETTER_DIGEST, { - payload: { type: 'creator', target: this.props.username }, - }); - break; default: Alert.alert('Action not implemented'); break; @@ -137,7 +137,6 @@ class ProfileSummaryView extends PureComponent { intl.formatMessage({ id: 'user.delegate' }), intl.formatMessage({ id: !isMuted ? 'user.mute' : 'user.unmute' }), intl.formatMessage({ id: 'user.report' }), - intl.formatMessage({ id: 'newsletter.profile_option' }), ]; } @@ -168,6 +167,17 @@ class ProfileSummaryView extends PureComponent { + + {isProfileLoading ? (