From 107cc178048b5e107ed9a01d2eb41af64760e720 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 13:54:28 +0000 Subject: [PATCH 1/3] fix(newsletter): follow-ups from reader-phase testing Uppercases the profile dropdown entry to match its siblings (the en-US values there are uppercase, not styled). Adds the end-of-post subscribe card the website has: the author's creator digest for a reader, the community digest for the author of a community post, nothing on one's own blog post; hidden while subscribed, dismissal remembered per viewer and list, gone before the storage answer arrives so it never flashes in. Adds the creator's own-profile list glance: weekly/monthly mailable subscriber counts from the sender view (owner-gated server-side), subscribe-link copy and a shortcut into digest management. Closes #3520 --- src/components/index.tsx | 4 + src/components/newsletterPostPrompt/index.ts | 1 + .../newsletterPostPrompt.tsx | 131 ++++++++++++++++++ .../postDigestTarget.test.ts | 39 ++++++ .../newsletterPostPrompt/postDigestTarget.ts | 39 ++++++ src/components/newsletterSenderInfo/index.ts | 1 + .../newsletterSenderInfo.tsx | 86 ++++++++++++ .../postView/view/postDisplayView.tsx | 2 + .../view/profileSummaryView.tsx | 3 + src/config/locales/en-US.json | 3 +- 10 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/components/newsletterPostPrompt/index.ts create mode 100644 src/components/newsletterPostPrompt/newsletterPostPrompt.tsx create mode 100644 src/components/newsletterPostPrompt/postDigestTarget.test.ts create mode 100644 src/components/newsletterPostPrompt/postDigestTarget.ts create mode 100644 src/components/newsletterSenderInfo/index.ts create mode 100644 src/components/newsletterSenderInfo/newsletterSenderInfo.tsx diff --git a/src/components/index.tsx b/src/components/index.tsx index 8f5cc8f767..0978d2d401 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -130,6 +130,8 @@ import { CommunityManageSheet } from './communityManageSheet'; import { CommunityRoleEditSheet } from './communityRoleEditSheet'; import { SearchFiltersSheet } from './searchFiltersSheet'; import { NewsletterDigestSheet } from './newsletterDigestSheet'; +import { NewsletterPostPrompt } from './newsletterPostPrompt'; +import { NewsletterSenderInfo } from './newsletterSenderInfo'; import TransferFavoritesSheet from './transferFavoritesSheet/transferFavoritesSheet'; // Basic UI Elements @@ -316,5 +318,7 @@ export { CommunityRoleEditSheet, SearchFiltersSheet, NewsletterDigestSheet, + NewsletterPostPrompt, + NewsletterSenderInfo, TransferFavoritesSheet, }; diff --git a/src/components/newsletterPostPrompt/index.ts b/src/components/newsletterPostPrompt/index.ts new file mode 100644 index 0000000000..e03a5ec414 --- /dev/null +++ b/src/components/newsletterPostPrompt/index.ts @@ -0,0 +1 @@ +export { default as NewsletterPostPrompt } from './newsletterPostPrompt'; diff --git a/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx b/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx new file mode 100644 index 0000000000..7a92402aaa --- /dev/null +++ b/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; +import { useIntl } from 'react-intl'; +import { SheetManager } from 'react-native-actions-sheet'; +import EStyleSheet from 'react-native-extended-stylesheet'; +import { SheetNames } from '../../navigation/sheets'; +import { useAuth } from '../../hooks'; +import { useDigestSubscription } from '../../providers/queries'; +import { getItemFromStorage, setItemToStorage } from '../../storage/storage'; +import { IconButton } from '../iconButton'; +import { pickPostDigestTarget, postPromptStorageKey } from './postDigestTarget'; + +interface Props { + post: + | { author?: string; category?: string; parent_author?: string; depth?: number } + | null + | undefined; +} + +/** + * End-of-post subscribe card (web parity, vision-mobile#3520): offers the + * digest that would carry this post. Never shown while a subscription for + * that list exists; an explicit dismissal is remembered per viewer AND list. + */ +const NewsletterPostPrompt = ({ post }: Props) => { + const intl = useIntl(); + const { username } = useAuth(); + + const target = useMemo(() => pickPostDigestTarget(post, username), [post, username]); + + // null = storage answer pending; the card must not flash in before it. + const [dismissed, setDismissed] = useState(null); + + const storageKey = + target && username ? postPromptStorageKey(username, target.type, target.target) : null; + + useEffect(() => { + let live = true; + setDismissed(null); + if (!storageKey) { + return undefined; + } + getItemFromStorage(storageKey).then((flag) => { + if (live) { + setDismissed(!!flag); + } + }); + return () => { + live = false; + }; + }, [storageKey]); + + const { subscription } = useDigestSubscription(target?.type ?? 'creator', target?.target ?? ''); + + if (!target || dismissed !== false || subscription) { + return null; + } + + const listLabel = target.type === 'creator' ? `@${target.target}` : target.target; + + const _handleDismiss = () => { + setDismissed(true); + if (storageKey) { + setItemToStorage(storageKey, { dismissedAt: new Date().toISOString() }); + } + }; + + const _handleSubscribe = () => { + SheetManager.show(SheetNames.NEWSLETTER_DIGEST, { + payload: { type: target.type, target: target.target }, + }); + }; + + return ( + + + + {intl.formatMessage({ id: `newsletter.body_${target.type}` }, { list: listLabel })} + + + + + {intl.formatMessage({ id: 'newsletter.subscribe' })} + + + + + ); +}; + +const styles = EStyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '$primaryLightBackground', + borderRadius: 12, + paddingVertical: 10, + paddingLeft: 12, + paddingRight: 4, + marginTop: 12, + marginHorizontal: 0, + }, + textWrapper: { + flex: 1, + marginRight: 8, + }, + text: { + fontSize: 13, + color: '$primaryDarkGray', + lineHeight: 18, + }, + subscribeButton: { + backgroundColor: '$primaryBlue', + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 7, + }, + subscribeText: { + fontSize: 13, + color: '$white', + fontWeight: '600', + }, +}); + +export default NewsletterPostPrompt; diff --git a/src/components/newsletterPostPrompt/postDigestTarget.test.ts b/src/components/newsletterPostPrompt/postDigestTarget.test.ts new file mode 100644 index 0000000000..6e74c2a882 --- /dev/null +++ b/src/components/newsletterPostPrompt/postDigestTarget.test.ts @@ -0,0 +1,39 @@ +import { pickPostDigestTarget, postPromptStorageKey } from './postDigestTarget'; + +const rootPost = { author: 'alice', category: 'photography', parent_author: '', depth: 0 }; +const communityPost = { author: 'alice', category: 'hive-125125', parent_author: '', depth: 0 }; + +describe('pickPostDigestTarget', () => { + it('offers a reader the author creator digest on a root post', () => { + expect(pickPostDigestTarget(rootPost, 'bob')).toEqual({ type: 'creator', target: 'alice' }); + expect(pickPostDigestTarget(communityPost, 'bob')).toEqual({ + type: 'creator', + target: 'alice', + }); + }); + + it('offers the author of a community post that community digest, and nothing on their own blog post', () => { + expect(pickPostDigestTarget(communityPost, 'alice')).toEqual({ + type: 'community', + target: 'hive-125125', + }); + expect(pickPostDigestTarget(rootPost, 'alice')).toBeNull(); + }); + + it('offers nothing on comments or to anonymous viewers', () => { + expect(pickPostDigestTarget({ ...rootPost, parent_author: 'x', depth: 1 }, 'bob')).toBeNull(); + expect(pickPostDigestTarget({ ...rootPost, depth: 2, parent_author: 'x' }, 'bob')).toBeNull(); + expect(pickPostDigestTarget(rootPost, null)).toBeNull(); + expect(pickPostDigestTarget(rootPost, undefined)).toBeNull(); + expect(pickPostDigestTarget(null, 'bob')).toBeNull(); + }); + + it('scopes the dismissal key per viewer and list', () => { + expect(postPromptStorageKey('bob', 'creator', 'alice')).toBe( + 'digest_post_prompt_bob_creator_alice', + ); + expect(postPromptStorageKey('bob', 'creator', 'alice')).not.toBe( + postPromptStorageKey('carol', 'creator', 'alice'), + ); + }); +}); diff --git a/src/components/newsletterPostPrompt/postDigestTarget.ts b/src/components/newsletterPostPrompt/postDigestTarget.ts new file mode 100644 index 0000000000..7bbb16e508 --- /dev/null +++ b/src/components/newsletterPostPrompt/postDigestTarget.ts @@ -0,0 +1,39 @@ +import { DigestType } from '@ecency/sdk'; +import { isCommunity } from '../../utils/communityValidation'; + +export interface PostDigestTarget { + type: DigestType; + target: string; +} + +/** + * Which digest an end-of-post card offers, mirroring the website: a reader is + * offered the AUTHOR's creator digest; the author reading their own COMMUNITY + * post is offered the community's digest (the list that would carry this + * post); the author's own non-community post offers nothing. Comments and + * anonymous viewers offer nothing (mobile subscribes are signed-in only). + */ +export const pickPostDigestTarget = ( + post: + | { author?: string; category?: string; parent_author?: string; depth?: number } + | null + | undefined, + viewer: string | null | undefined, +): PostDigestTarget | null => { + if (!post?.author || !viewer) { + return null; + } + const isRoot = !post.parent_author && !(typeof post.depth === 'number' && post.depth > 0); + if (!isRoot) { + return null; + } + const community = post.category && isCommunity(post.category) ? post.category : null; + if (viewer === post.author) { + return community ? { type: 'community', target: community } : null; + } + return { type: 'creator', target: post.author }; +}; + +/** Per viewer AND list, so dismissing one author's card never hides another's. */ +export const postPromptStorageKey = (viewer: string, type: string, target: string) => + `digest_post_prompt_${viewer}_${type}_${target}`; diff --git a/src/components/newsletterSenderInfo/index.ts b/src/components/newsletterSenderInfo/index.ts new file mode 100644 index 0000000000..e3ff8667d0 --- /dev/null +++ b/src/components/newsletterSenderInfo/index.ts @@ -0,0 +1 @@ +export { default as NewsletterSenderInfo } from './newsletterSenderInfo'; diff --git a/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx b/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx new file mode 100644 index 0000000000..41b6b065ed --- /dev/null +++ b/src/components/newsletterSenderInfo/newsletterSenderInfo.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; +import { useIntl } from 'react-intl'; +import { useNavigation } from '@react-navigation/native'; +import { useQuery } from '@tanstack/react-query'; +import Clipboard from '@react-native-clipboard/clipboard'; +import EStyleSheet from 'react-native-extended-stylesheet'; +import { getNewsletterSenderQueryOptions } from '@ecency/sdk'; +import ROUTES from '../../constants/routeNames'; +import { useAppDispatch, useAuth } from '../../hooks'; +import { toastNotification } from '../../redux/actions/uiAction'; +import { IconButton } from '../iconButton'; + +interface Props { + username: string; +} + +/** + * The creator's own list at a glance on their profile (web parity, + * vision-mobile#3520): weekly/monthly mailable subscriber counts, the + * copyable subscribe link, and the way into digest management. The sender + * 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 intl = useIntl(); + const dispatch = useAppDispatch(); + const navigation = useNavigation(); + const { username: authUsername, code } = useAuth(); + + const senderQuery = useQuery( + getNewsletterSenderQueryOptions('creator', username, authUsername, code), + ); + + const subscribers = senderQuery.data?.subscribers; + if (!subscribers) { + return null; + } + + const _handleCopyLink = () => { + Clipboard.setString(`https://ecency.com/@${username}?subscribe=digest`); + dispatch(toastNotification(intl.formatMessage({ id: 'alert.copied' }))); + }; + + return ( + + + {intl.formatMessage( + { id: 'newsletter.subscriber_count' }, + { weekly: subscribers.weekly ?? 0, monthly: subscribers.monthly ?? 0 }, + )} + + + navigation.navigate(ROUTES.SCREENS.EMAIL_DIGESTS)}> + {intl.formatMessage({ id: 'newsletter.manage' })} + + + ); +}; + +const styles = EStyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + paddingTop: 6, + }, + countText: { + fontSize: 13, + color: '$primaryDarkGray', + }, + manageText: { + fontSize: 13, + color: '$primaryBlue', + marginLeft: 8, + }, +}); + +export default NewsletterSenderInfo; diff --git a/src/components/postView/view/postDisplayView.tsx b/src/components/postView/view/postDisplayView.tsx index 4d928788d8..c6fa8d9176 100644 --- a/src/components/postView/view/postDisplayView.tsx +++ b/src/components/postView/view/postDisplayView.tsx @@ -28,6 +28,7 @@ import { PostTypes } from '../../../constants/postTypes'; import { useCheckIn } from '../../../providers/queries/pointQueries'; import { PostComments } from '../../postComments'; import { SimilarEntries } from '../../similarEntries'; +import { NewsletterPostPrompt } from '../../newsletterPostPrompt'; import { UpvoteButton } from '../../postCard/children/upvoteButton'; import UpvotePopover from '../../upvotePopover'; import { PostPoll } from '../../postPoll'; @@ -527,6 +528,7 @@ const PostDisplayView = ({ /> )} + {!postBodyLoading && } {!postBodyLoading && } )} diff --git a/src/components/profileSummary/view/profileSummaryView.tsx b/src/components/profileSummary/view/profileSummaryView.tsx index 70815c5796..bcb7456f23 100644 --- a/src/components/profileSummary/view/profileSummaryView.tsx +++ b/src/components/profileSummary/view/profileSummaryView.tsx @@ -24,6 +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'; const DEVICE_WIDTH = getWindowDimensions().width; @@ -314,6 +315,7 @@ class ProfileSummaryView extends PureComponent { }; render() { + const { isOwnProfile, username } = this.props; return ( {this._renderCoverImage()} @@ -321,6 +323,7 @@ class ProfileSummaryView extends PureComponent { {this._renderIdentity()} {this._renderMetadata()} {this._renderFollowerStats()} + {!!isOwnProfile && !!username && } {this._renderBars()} ); diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index 3772e022c6..8c04af3b48 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -1899,7 +1899,8 @@ "stop_all_body": "No Ecency mail will be sent to {email} again. Subscriptions under other addresses stay.", "stop_all_ok": "Stop all", "stop_all_done": "Address unsubscribed from all mail", - "profile_option": "Email digest", + "profile_option": "EMAIL DIGEST", + "subscriber_count": "{weekly} weekly \u2022 {monthly} monthly email subscribers", "community_button": "Newsletter" }, "mod_notes": { From 9de59a03e74b2136f9173938b7899be48061ad90 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 14:01:10 +0000 Subject: [PATCH 2/3] fix(newsletter): render the post prompt only after the subscription lookup succeeds Unresolved data is not-known-yet, not not-subscribed: rendering on the storage answer alone flashed the card at existing subscribers on a cold cache and left it standing when the lookup failed. --- .../newsletterPostPrompt/newsletterPostPrompt.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx b/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx index 7a92402aaa..31c059fbb2 100644 --- a/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx +++ b/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx @@ -50,9 +50,19 @@ const NewsletterPostPrompt = ({ post }: Props) => { }; }, [storageKey]); - const { subscription } = useDigestSubscription(target?.type ?? 'creator', target?.target ?? ''); + const subscriptionQuery = useDigestSubscription(target?.type ?? 'creator', target?.target ?? ''); - if (!target || dismissed !== false || subscription) { + // Render only once BOTH answers are in: the storage flag AND a successful + // subscriptions lookup. Unresolved data is "don't know", not "not + // subscribed" — rendering early would flash the card at existing + // subscribers, and on a failed lookup it would offer a sheet that can only + // report the service as unavailable. + if ( + !target || + dismissed !== false || + !subscriptionQuery.isSuccess || + subscriptionQuery.subscription + ) { return null; } From a16c582da821bb08ad9345866d9c2f56cdbd57b5 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 14:06:27 +0000 Subject: [PATCH 3/3] fix(newsletter): harden the post prompt edges Anchor the community check locally: the shared isCommunity() matches only the suffix, so other-hive-125125 would target a digest list that does not exist. Handle both dismissal storage rejections: a failed read falls back to offering (a failing store cannot have persisted a dismissal either) and a failed write costs only persistence instead of an unhandled rejection. --- .../newsletterPostPrompt.tsx | 22 ++++++++++++++----- .../postDigestTarget.test.ts | 8 +++++++ .../newsletterPostPrompt/postDigestTarget.ts | 8 +++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx b/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx index 31c059fbb2..d003280a19 100644 --- a/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx +++ b/src/components/newsletterPostPrompt/newsletterPostPrompt.tsx @@ -40,11 +40,19 @@ const NewsletterPostPrompt = ({ post }: Props) => { if (!storageKey) { return undefined; } - getItemFromStorage(storageKey).then((flag) => { - if (live) { - setDismissed(!!flag); - } - }); + getItemFromStorage(storageKey) + .then((flag) => { + if (live) { + setDismissed(!!flag); + } + }) + // A failing store also cannot have PERSISTED a dismissal, so offering + // is the consistent outcome; leaving null would hide the card forever. + .catch(() => { + if (live) { + setDismissed(false); + } + }); return () => { live = false; }; @@ -71,7 +79,9 @@ const NewsletterPostPrompt = ({ post }: Props) => { const _handleDismiss = () => { setDismissed(true); if (storageKey) { - setItemToStorage(storageKey, { dismissedAt: new Date().toISOString() }); + // The in-session state above already hides the card; a lost write only + // costs persistence, never an unhandled rejection. + setItemToStorage(storageKey, { dismissedAt: new Date().toISOString() }).catch(() => {}); } }; diff --git a/src/components/newsletterPostPrompt/postDigestTarget.test.ts b/src/components/newsletterPostPrompt/postDigestTarget.test.ts index 6e74c2a882..bdeace716c 100644 --- a/src/components/newsletterPostPrompt/postDigestTarget.test.ts +++ b/src/components/newsletterPostPrompt/postDigestTarget.test.ts @@ -20,6 +20,14 @@ describe('pickPostDigestTarget', () => { expect(pickPostDigestTarget(rootPost, 'alice')).toBeNull(); }); + it('treats a non-canonical category as a plain tag, not a community', () => { + const oddPost = { ...communityPost, category: 'other-hive-125125' }; + // The author of such a post gets no community offer; a reader still gets + // the creator digest. + expect(pickPostDigestTarget(oddPost, 'alice')).toBeNull(); + expect(pickPostDigestTarget(oddPost, 'bob')).toEqual({ type: 'creator', target: 'alice' }); + }); + it('offers nothing on comments or to anonymous viewers', () => { expect(pickPostDigestTarget({ ...rootPost, parent_author: 'x', depth: 1 }, 'bob')).toBeNull(); expect(pickPostDigestTarget({ ...rootPost, depth: 2, parent_author: 'x' }, 'bob')).toBeNull(); diff --git a/src/components/newsletterPostPrompt/postDigestTarget.ts b/src/components/newsletterPostPrompt/postDigestTarget.ts index 7bbb16e508..756d899e27 100644 --- a/src/components/newsletterPostPrompt/postDigestTarget.ts +++ b/src/components/newsletterPostPrompt/postDigestTarget.ts @@ -1,5 +1,9 @@ import { DigestType } from '@ecency/sdk'; -import { isCommunity } from '../../utils/communityValidation'; + +// Anchored ON PURPOSE: the shared isCommunity() matches only the suffix, so a +// category like `other-hive-125125` would pass and target a digest list that +// does not exist. A digest target must be the canonical community id. +const COMMUNITY_RE = /^hive-[1-3]\d{4,6}$/; export interface PostDigestTarget { type: DigestType; @@ -27,7 +31,7 @@ export const pickPostDigestTarget = ( if (!isRoot) { return null; } - const community = post.category && isCommunity(post.category) ? post.category : null; + const community = post.category && COMMUNITY_RE.test(post.category) ? post.category : null; if (viewer === post.author) { return community ? { type: 'community', target: community } : null; }