From e8a1f9be9a64a66df21583070e99bbad3e3c16c8 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 10:42:57 +0000 Subject: [PATCH 1/3] feat(newsletter): email digest subscriptions, reader phase Adds the newsletter reader phase on the shared @ecency/sdk 2.3.93 client (relay accepts the mobile-app source since vision-web#1660): a globally registered digest sheet (email input only while no address is on file, weekly/monthly selector, subscribe/update/resend/leave, check-your-inbox state for double opt-in), an Email digests screen reached from a login-gated Settings row (per-address grouping, stop-all with confirm, join rows for the own-notifications digest and the Ecency newsletter), entry points on the profile dropdown and the community screen, and a one-time own-digest offer after the account's first root publish (flag written before showing). Signed-in calls need no captcha; a 503 from the relay renders as an unavailable state. Sheet results follow the truthy-object convention. Closes #3518 --- package.json | 2 +- src/components/index.tsx | 2 + src/components/newsletterDigestSheet/index.ts | 2 + .../newsletterDigestSheet.tsx | 354 ++++++++++++++++++ .../view/profileSummaryView.tsx | 10 + src/config/locales/en-US.json | 46 +++ src/constants/routeNames.ts | 1 + src/constants/settingsTypes.ts | 2 + src/navigation/sheets.tsx | 22 +- src/navigation/stackNavigator.tsx | 2 + src/navigation/types.ts | 1 + src/providers/queries/index.ts | 1 + .../queries/newsletterQueries.test.ts | 66 ++++ src/providers/queries/newsletterQueries.ts | 65 ++++ .../community/screen/communityScreen.tsx | 18 + .../editor/container/editorContainer.tsx | 11 + src/screens/emailDigests/index.ts | 4 + .../screen/emailDigestsScreen.tsx | 246 ++++++++++++ src/screens/index.ts | 2 + .../settings/container/settingsContainer.tsx | 4 + .../settings/screen/settingsScreen.tsx | 13 + src/utils/firstPublishDigest.test.ts | 74 ++++ src/utils/firstPublishDigest.ts | 49 +++ yarn.lock | 8 +- 24 files changed, 999 insertions(+), 6 deletions(-) create mode 100644 src/components/newsletterDigestSheet/index.ts create mode 100644 src/components/newsletterDigestSheet/newsletterDigestSheet.tsx create mode 100644 src/providers/queries/newsletterQueries.test.ts create mode 100644 src/providers/queries/newsletterQueries.ts create mode 100644 src/screens/emailDigests/index.ts create mode 100644 src/screens/emailDigests/screen/emailDigestsScreen.tsx create mode 100644 src/utils/firstPublishDigest.test.ts create mode 100644 src/utils/firstPublishDigest.ts diff --git a/package.json b/package.json index fa141a85d3..66549dafd0 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@babel/preset-typescript": "^7.26.0", "@babel/runtime": "^7.26.7", "@ecency/render-helper": "^2.5.23", - "@ecency/sdk": "^2.3.87", + "@ecency/sdk": "^2.3.93", "@esteemapp/react-native-autocomplete-input": "^4.2.1", "@esteemapp/react-native-multi-slider": "^1.1.0", "@native-html/iframe-plugin": "^2.6.1", diff --git a/src/components/index.tsx b/src/components/index.tsx index 63aaf46e61..8f5cc8f767 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -129,6 +129,7 @@ import { ModNotesSheet } from './modNotesSheet'; import { CommunityManageSheet } from './communityManageSheet'; import { CommunityRoleEditSheet } from './communityRoleEditSheet'; import { SearchFiltersSheet } from './searchFiltersSheet'; +import { NewsletterDigestSheet } from './newsletterDigestSheet'; import TransferFavoritesSheet from './transferFavoritesSheet/transferFavoritesSheet'; // Basic UI Elements @@ -314,5 +315,6 @@ export { CommunityManageSheet, CommunityRoleEditSheet, SearchFiltersSheet, + NewsletterDigestSheet, TransferFavoritesSheet, }; diff --git a/src/components/newsletterDigestSheet/index.ts b/src/components/newsletterDigestSheet/index.ts new file mode 100644 index 0000000000..2970a51678 --- /dev/null +++ b/src/components/newsletterDigestSheet/index.ts @@ -0,0 +1,2 @@ +export { default as NewsletterDigestSheet } from './newsletterDigestSheet'; +export type { NewsletterDigestResult } from './newsletterDigestSheet'; diff --git a/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx b/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx new file mode 100644 index 0000000000..4777e9a0ca --- /dev/null +++ b/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx @@ -0,0 +1,354 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { useIntl } from 'react-intl'; +import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; +import EStyleSheet from 'react-native-extended-stylesheet'; +import { DigestCadence } from '@ecency/sdk'; +import { MainButton } from '../mainButton'; +import { useAppDispatch } from '../../hooks'; +import { toastNotification } from '../../redux/actions/uiAction'; +import { + MOBILE_DIGEST_SOURCE, + findDigestSubscription, + knownDigestAddress, + useDigestSubscriptionsQuery, + useLeaveDigestMutation, + useSubscribeDigestMutation, +} from '../../providers/queries'; + +const FALLBACK_SHEET_ID = 'newsletter_digest'; + +/** + * Result of the sheet. Both variants are objects because + * react-native-actions-sheet 0.9.7 publishes `data || payloadRef.current` on + * close, so a falsy return value is silently replaced by the original payload + * object; callers must gate on a field, never on truthiness. + */ +export interface NewsletterDigestResult { + done?: boolean; + cancelled?: boolean; +} + +const CADENCES: DigestCadence[] = ['weekly', 'monthly']; + +// Enough to catch a typo before the relay does; the service still validates +// and double opt-in proves ownership either way. +const EMAIL_RE = /^\S+@\S+\.\S+$/; + +/** + * Subscribe to / manage ONE email digest list (own notifications, a creator, + * a community, or the site newsletter). Signed-in only: the relay attributes + * the subscription to the verified account and skips the captcha. A new + * address gets double opt-in, surfaced here as the check-your-inbox state. + */ +const NewsletterDigestSheet: React.FC> = ({ sheetId, payload }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const closedRef = useRef(false); + + const [email, setEmail] = useState(''); + const [cadence, setCadence] = useState(null); + const [checkInboxEmail, setCheckInboxEmail] = useState(''); + + const type = payload?.type ?? 'site'; + const target = payload?.target ?? 'ecency'; + + const subscriptionsQuery = useDigestSubscriptionsQuery(); + const subscription = findDigestSubscription(subscriptionsQuery.data, type, target); + const knownAddress = subscription?.email || knownDigestAddress(subscriptionsQuery.data); + + const subscribeMutation = useSubscribeDigestMutation(); + const leaveMutation = useLeaveDigestMutation(); + + const _reset = useCallback(() => { + closedRef.current = false; + setEmail(''); + setCadence(null); + setCheckInboxEmail(''); + }, []); + + // Registered sheets stay mounted; onBeforeShow is the authoritative reset + // and the effect covers a payload swap while the sheet is already open. + useEffect(() => { + _reset(); + }, [payload, _reset]); + + const _close = (result: NewsletterDigestResult) => { + if (closedRef.current) { + return; + } + closedRef.current = true; + SheetManager.hide(sheetId || FALLBACK_SHEET_ID, { payload: result }); + }; + + const effectiveCadence: DigestCadence = cadence ?? subscription?.cadence ?? 'weekly'; + const needsEmailInput = !knownAddress; + const emailValid = EMAIL_RE.test(email.trim()); + const isPending = subscribeMutation.isPending || leaveMutation.isPending; + + const _listLabel = () => { + switch (type) { + case 'own': + return intl.formatMessage({ id: 'newsletter.list_own' }); + case 'site': + return intl.formatMessage({ id: 'newsletter.list_site' }); + case 'community': + return payload?.targetLabel || target; + default: + return `@${target}`; + } + }; + + const _handleSubscribe = async () => { + const address = knownAddress || email.trim(); + if (!address) { + return; + } + try { + const result = await subscribeMutation.mutateAsync({ + email: address, + type, + target, + cadence: effectiveCadence, + source: MOBILE_DIGEST_SOURCE, + }); + if (result.status === 'refused') { + Alert.alert(intl.formatMessage({ id: 'newsletter.refused' })); + return; + } + if (result.status === 'pending_confirmation') { + setCheckInboxEmail(address); + return; + } + dispatch(toastNotification(intl.formatMessage({ id: 'newsletter.subscribed' }))); + _close({ done: true }); + } catch (err) { + const status = (err as { status?: number })?.status; + Alert.alert( + intl.formatMessage({ + id: status === 429 ? 'newsletter.too_many' : 'newsletter.fail', + }), + ); + } + }; + + const _handleLeave = async () => { + if (!subscription) { + return; + } + try { + await leaveMutation.mutateAsync(subscription.id); + dispatch(toastNotification(intl.formatMessage({ id: 'newsletter.left' }))); + _close({ done: true }); + } catch (err) { + Alert.alert(intl.formatMessage({ id: 'newsletter.fail' })); + } + }; + + const _renderCheckInbox = () => ( + + {intl.formatMessage({ id: 'newsletter.check_inbox_title' })} + + {intl.formatMessage({ id: 'newsletter.check_inbox_body' }, { email: checkInboxEmail })} + + _close({ done: true })} + text={intl.formatMessage({ id: 'newsletter.ok' })} + style={styles.confirmButton} + /> + + ); + + const isPendingConfirmation = subscription?.status === 'pending_confirmation'; + const isActive = subscription?.status === 'active'; + const cadenceUnchanged = isActive && effectiveCadence === subscription?.cadence; + + const primaryLabelId = (() => { + if (isPendingConfirmation && effectiveCadence === subscription?.cadence) { + return 'newsletter.resend'; + } + if (isActive || isPendingConfirmation) { + return 'newsletter.update'; + } + return 'newsletter.subscribe'; + })(); + + const _renderForm = () => ( + + + {payload?.firstPublish + ? intl.formatMessage({ id: 'newsletter.first_publish_title' }) + : intl.formatMessage({ id: 'newsletter.title' }, { list: _listLabel() })} + + + {payload?.firstPublish + ? intl.formatMessage({ id: 'newsletter.first_publish_body' }) + : intl.formatMessage({ id: `newsletter.body_${type}` }, { list: _listLabel() })} + + + {isActive && ( + {intl.formatMessage({ id: 'newsletter.status_active' })} + )} + {isPendingConfirmation && ( + {intl.formatMessage({ id: 'newsletter.status_pending' })} + )} + + + {CADENCES.map((option) => { + const selected = effectiveCadence === option; + return ( + setCadence(option)} + disabled={isPending} + > + + {intl.formatMessage({ id: `newsletter.cadence_${option}` })} + + + ); + })} + + + {needsEmailInput && ( + + )} + + + + {!!subscription && ( + + )} + + _close({ cancelled: true })} + text={intl.formatMessage({ id: 'newsletter.cancel' })} + style={styles.cancelButton} + textStyle={styles.cancelButtonText} + /> + + ); + + return ( + + {checkInboxEmail ? _renderCheckInbox() : _renderForm()} + + ); +}; + +const styles = EStyleSheet.create({ + sheetContainer: { + paddingHorizontal: 0, + backgroundColor: '$primaryBackgroundColor', + }, + container: { + paddingHorizontal: 20, + paddingVertical: 24, + paddingBottom: 40, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + color: '$primaryBlack', + textAlign: 'center', + marginBottom: 8, + }, + description: { + fontSize: 15, + color: '$primaryDarkGray', + textAlign: 'center', + marginBottom: 16, + lineHeight: 22, + }, + status: { + fontSize: 13, + color: '$primaryBlue', + textAlign: 'center', + marginBottom: 12, + }, + cadenceRow: { + flexDirection: 'row', + justifyContent: 'center', + marginBottom: 16, + }, + cadenceButton: { + borderWidth: 1, + borderColor: '$primaryLightGray', + backgroundColor: '$primaryLightBackground', + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 8, + marginHorizontal: 4, + }, + cadenceButtonSelected: { + backgroundColor: '$primaryBlue', + borderColor: '$primaryBlue', + }, + cadenceText: { + fontSize: 14, + color: '$primaryBlack', + }, + cadenceTextSelected: { + color: '$white', + }, + input: { + borderWidth: 1, + borderColor: '$primaryLightGray', + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + color: '$primaryBlack', + backgroundColor: '$primaryLightBackground', + marginBottom: 16, + }, + confirmButton: { + marginBottom: 0, + }, + leaveButton: { + backgroundColor: 'transparent', + marginTop: 8, + }, + leaveButtonText: { + color: '$primaryRed', + }, + cancelButton: { + backgroundColor: 'transparent', + marginTop: 8, + }, + cancelButtonText: { + color: '$primaryDarkGray', + }, +}); + +export default NewsletterDigestSheet; diff --git a/src/components/profileSummary/view/profileSummaryView.tsx b/src/components/profileSummary/view/profileSummaryView.tsx index 56aa1fbf74..70815c5796 100644 --- a/src/components/profileSummary/view/profileSummaryView.tsx +++ b/src/components/profileSummary/view/profileSummaryView.tsx @@ -1,6 +1,7 @@ import React, { PureComponent, Fragment } from 'react'; import { View, Text, TouchableOpacity, ActivityIndicator, Linking, Alert } from 'react-native'; import get from 'lodash/get'; +import { SheetManager } from 'react-native-actions-sheet'; // Constants import { Image as ExpoImage } from 'expo-image'; @@ -22,6 +23,7 @@ import { makeCountFriendly } from '../../../utils/formatter'; // Styles import styles from './profileSummaryStyles'; import getWindowDimensions from '../../../utils/getWindowDimensions'; +import { SheetNames } from '../../../navigation/sheets'; const DEVICE_WIDTH = getWindowDimensions().width; @@ -70,6 +72,13 @@ 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; @@ -128,6 +137,7 @@ 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' }), ]; } diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index d28147d814..3772e022c6 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -1736,6 +1736,7 @@ }, "send_feedback": "Send Feedback", "rate_app": "Rate Ecency", + "email_digests": "Email digests", "rate": "Rate", "dm-privacy-updated": "DM privacy settings updated successfully", "dm-privacy-failed": "Failed to update DM privacy settings", @@ -1856,6 +1857,51 @@ "wave": "Wave", "wave_desc": "Share a quick update" }, + "newsletter": { + "screen_title": "Email digests", + "title": "{list}", + "body_own": "A weekly or monthly email summary of your notifications.", + "body_creator": "New posts by {list} in your inbox.", + "body_community": "A digest of new posts in {list} by email.", + "body_site": "The best of Ecency in your inbox.", + "first_publish_title": "Congrats on your first post!", + "first_publish_body": "Want an email digest of your notifications? Weekly or monthly, change or leave it anytime in Settings.", + "list_own": "My notifications digest", + "list_site": "Ecency newsletter", + "status_active": "You are subscribed.", + "status_pending": "Awaiting email confirmation.", + "status_short_active": "active", + "status_short_pending_confirmation": "pending", + "status_short_suppressed": "stopped", + "status_short_ended": "ended", + "cadence_weekly": "Weekly", + "cadence_monthly": "Monthly", + "email_placeholder": "you@example.com", + "subscribe": "Subscribe", + "update": "Update", + "resend": "Resend confirmation", + "leave": "Leave digest", + "cancel": "Cancel", + "ok": "OK", + "manage": "Manage", + "check_inbox_title": "Check your inbox", + "check_inbox_body": "We sent a confirmation link to {email}. The subscription starts once you confirm.", + "subscribed": "Subscribed to email digest", + "left": "Left the digest", + "refused": "This address cannot be subscribed.", + "unavailable": "Email digests are not available right now.", + "too_many": "Too many attempts, please try again later.", + "fail": "Something went wrong, please try again.", + "empty": "No email digests yet. Subscribe below to get started.", + "discover": "Get more by email", + "stop_all": "Stop all", + "stop_all_title": "Stop all email?", + "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", + "community_button": "Newsletter" + }, "mod_notes": { "title": "Add a reason", "placeholder": "Reason (visible to other moderators)", diff --git a/src/constants/routeNames.ts b/src/constants/routeNames.ts index a7dfeeaf67..1364172722 100644 --- a/src/constants/routeNames.ts +++ b/src/constants/routeNames.ts @@ -44,6 +44,7 @@ const ROUTES = { TRADE: `Trade${SCREEN_SUFFIX}`, AI_IMAGE_GENERATOR: `AiImageGenerator${SCREEN_SUFFIX}`, DAPP_BROWSER: `DappBrowser${SCREEN_SUFFIX}`, + EMAIL_DIGESTS: `EmailDigests${SCREEN_SUFFIX}`, }, MODALS: { ASSETS_SELECT: `AssetsSelect${MODAL_SUFFIX}`, diff --git a/src/constants/settingsTypes.ts b/src/constants/settingsTypes.ts index 5cd533752e..d1a8efbf0c 100644 --- a/src/constants/settingsTypes.ts +++ b/src/constants/settingsTypes.ts @@ -4,6 +4,7 @@ const BACKUP_PRIVATE_KEYS = 'backup_private_keys'; const DM_PRIVACY = 'dm_privacy'; const IMAGE_SERVER = 'image_server'; const RATE_APP = 'rate_app'; +const EMAIL_DIGESTS = 'email_digests'; const SUPPORT_BENEFICIARY = 'support_beneficiary'; const SUPPORT_BENEFICIARY_PERCENT = 'support_beneficiary_percent'; const SUPPORT_CURATION = 'support_curation'; @@ -16,6 +17,7 @@ export default { DM_PRIVACY, IMAGE_SERVER, RATE_APP, + EMAIL_DIGESTS, SUPPORT_BENEFICIARY, SUPPORT_BENEFICIARY_PERCENT, SUPPORT_CURATION, diff --git a/src/navigation/sheets.tsx b/src/navigation/sheets.tsx index 7f1e318d34..59e6e2e993 100644 --- a/src/navigation/sheets.tsx +++ b/src/navigation/sheets.tsx @@ -1,5 +1,5 @@ import { registerSheet, SheetDefinition, type Sheets } from 'react-native-actions-sheet'; -import type { Operation } from '@ecency/sdk'; +import type { DigestType, Operation } from '@ecency/sdk'; import { ActionModal, PostTranslationModal, @@ -19,11 +19,13 @@ import { ComposeTranslateModal, TransferFavoritesSheet, ModNotesSheet, + NewsletterDigestSheet, CommunityManageSheet, CommunityRoleEditSheet, SearchFiltersSheet, } from '../components'; import type { ModNotesResult } from '../components/modNotesSheet/modNotesSheet'; +import type { NewsletterDigestResult } from '../components/newsletterDigestSheet/newsletterDigestSheet'; import type { CommunityManageAction } from '../components/communityManageSheet/communityManageSheet'; import type { CommunityRoleEditResult } from '../components/communityRoleEditSheet/communityRoleEditSheet'; import type { SearchFilters } from '../components/searchFiltersSheet'; @@ -65,6 +67,7 @@ export enum SheetNames { COMMUNITY_ROLE_EDIT = 'community_role_edit', SEARCH_FILTERS = 'search_filters', WALLET_HISTORY_FILTERS = 'wallet_history_filters', + NEWSLETTER_DIGEST = 'newsletter_digest', } registerSheet(SheetNames.POST_TRANSLATION, PostTranslationModal); @@ -95,6 +98,7 @@ registerSheet(SheetNames.COMMUNITY_MANAGE, CommunityManageSheet); registerSheet(SheetNames.COMMUNITY_ROLE_EDIT, CommunityRoleEditSheet); registerSheet(SheetNames.SEARCH_FILTERS, SearchFiltersSheet); registerSheet(SheetNames.WALLET_HISTORY_FILTERS, WalletHistoryFiltersSheet); +registerSheet(SheetNames.NEWSLETTER_DIGEST, NewsletterDigestSheet); // We extend some of the types here to give us great intellisense // across the app for all registered sheets. @@ -324,6 +328,22 @@ declare module 'react-native-actions-sheet' { // on `operations` being an array rather than on truthiness. returnValue: { operations?: string[]; cancelled?: boolean } | undefined; }>; + newsletter_digest: SheetDefinition<{ + payload: { + // Which list: 'own' (target = own username), 'creator' (target = author), + // 'community' (target = hive-xxxxx), 'site' (target = 'ecency'). + type: DigestType; + target: string; + // Display name for community lists (the community title). + targetLabel?: string; + // First-publish flavor: prompt copy instead of the generic title/body. + firstPublish?: boolean; + }; + // `{ done: true }` after a completed action, `{ cancelled: true }` on cancel. + // A backdrop/swipe/back dismissal resolves the payload object; gate on the + // field, never on truthiness. + returnValue: NewsletterDigestResult | undefined; + }>; } } diff --git a/src/navigation/stackNavigator.tsx b/src/navigation/stackNavigator.tsx index afeb00d09e..6932b3b7fd 100644 --- a/src/navigation/stackNavigator.tsx +++ b/src/navigation/stackNavigator.tsx @@ -31,6 +31,7 @@ import { CommunityMembers, CommunitySettings, CommunityActivities, + EmailDigests, Communities, WebBrowser, ReferScreen, @@ -90,6 +91,7 @@ const MainStackNavigator = () => { + diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 98abe44991..ca91558ff7 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -170,6 +170,7 @@ export type AppParamList = { | { onInsert?: (url: string) => void; suggestedPrompt?: string } | undefined; [ROUTES.SCREENS.DAPP_BROWSER]: { url?: string } | undefined; + [ROUTES.SCREENS.EMAIL_DIGESTS]: undefined; [ROUTES.MODALS.ASSETS_SELECT]: undefined; [ROUTES.MODALS.ACCOUNT_LIST]: { users?: any[]; title?: string } | undefined; [ROUTES.MODALS.POLL_WIZARD]: { draftId?: string } | undefined; diff --git a/src/providers/queries/index.ts b/src/providers/queries/index.ts index 3a27e1fd8e..b4f16c7d39 100644 --- a/src/providers/queries/index.ts +++ b/src/providers/queries/index.ts @@ -140,3 +140,4 @@ export * from './proQueries'; export * from './statsQueries'; export * from './searchQueries'; export * from './communityQueries'; +export * from './newsletterQueries'; diff --git a/src/providers/queries/newsletterQueries.test.ts b/src/providers/queries/newsletterQueries.test.ts new file mode 100644 index 0000000000..18f0b34dcc --- /dev/null +++ b/src/providers/queries/newsletterQueries.test.ts @@ -0,0 +1,66 @@ +// The hooks barrel drags native module chains (expo etc.), so stub it before +// importing the module under test; only the pure exports are exercised here. +jest.mock('../../hooks', () => ({ + useAuth: jest.fn(() => ({ username: undefined, code: undefined })), +})); + +// eslint-disable-next-line import/first +import fs from 'fs'; +// eslint-disable-next-line import/first +import path from 'path'; +// The queries barrel drags store/navigation/native chains, so import the +// module file directly and assert the barrel re-export textually instead +// (communityQueries.test.ts precedent). +// eslint-disable-next-line import/first +import type { DigestSubscription } from '@ecency/sdk'; +// eslint-disable-next-line import/first +import { + MOBILE_DIGEST_SOURCE, + findDigestSubscription, + knownDigestAddress, +} from './newsletterQueries'; + +const sub = (over: Partial): DigestSubscription => ({ + id: 'id-1', + email: 'alice@example.com', + account: 'alice', + type: 'creator', + target: 'alice', + cadence: 'weekly', + status: 'active', + created_at: '2026-08-25T00:00:00Z', + ...over, +}); + +describe('findDigestSubscription', () => { + it('matches on type AND target, target case-insensitively', () => { + const subs = [ + sub({ id: 'a', type: 'creator', target: 'Alice' }), + sub({ id: 'b', type: 'community', target: 'hive-125125' }), + ]; + expect(findDigestSubscription(subs, 'creator', 'alice')?.id).toBe('a'); + expect(findDigestSubscription(subs, 'community', 'HIVE-125125')?.id).toBe('b'); + expect(findDigestSubscription(subs, 'community', 'alice')).toBeUndefined(); + expect(findDigestSubscription(undefined, 'creator', 'alice')).toBeUndefined(); + }); +}); + +describe('knownDigestAddress', () => { + it('returns the first address on file, null when none is known', () => { + expect(knownDigestAddress(undefined)).toBeNull(); + expect(knownDigestAddress([])).toBeNull(); + expect(knownDigestAddress([sub({ email: 'a@example.com' })])).toBe('a@example.com'); + }); +}); + +describe('module wiring', () => { + it('uses the relay-allowlisted source value', () => { + // The relay 400s any unknown source, so this string is a contract. + expect(MOBILE_DIGEST_SOURCE).toBe('mobile-app'); + }); + + it('is re-exported from the queries barrel', () => { + const barrel = fs.readFileSync(path.join(__dirname, 'index.ts'), 'utf8'); + expect(barrel).toMatch(/export \* from '\.\/newsletterQueries';/); + }); +}); diff --git a/src/providers/queries/newsletterQueries.ts b/src/providers/queries/newsletterQueries.ts new file mode 100644 index 0000000000..a8aa214819 --- /dev/null +++ b/src/providers/queries/newsletterQueries.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query'; +import { + DigestSubscription, + DigestType, + getDigestSubscriptionsQueryOptions, + useLeaveDigest, + useSubscribeDigest, + useUnsubscribeAllDigests, +} from '@ecency/sdk'; +import { useAuth } from '../../hooks'; + +/** + * Email digest subscriptions (the newsletter reader phase, vision-mobile#3518). + * Transport lives in @ecency/sdk (shared with web): the relay at + * {privateApiHost}/api/newsletter/* authenticates POSTs from the body `code` + * and GET/DELETE from the X-HS-Token header; signed-in callers need no + * captcha. These wrappers only bind the SDK hooks to the mobile auth context. + */ + +/** The source value the relay's allowlist accepts for this app (vision-web#1660). */ +export const MOBILE_DIGEST_SOURCE = 'mobile-app' as const; + +/** The signed-in account's digest subscription for one list, if any. */ +export const findDigestSubscription = ( + subscriptions: DigestSubscription[] | undefined, + type: DigestType, + target: string, +): DigestSubscription | undefined => + (subscriptions ?? []).find( + (s) => s.type === type && s.target.toLowerCase() === target.toLowerCase(), + ); + +/** + * The address the service already holds for this account, learned from any + * live subscription. When known, a further subscribe is one action; when + * unknown, the person is asked for an address. + */ +export const knownDigestAddress = ( + subscriptions: DigestSubscription[] | undefined, +): string | null => subscriptions?.find((s) => s.email)?.email ?? null; + +export const useDigestSubscriptionsQuery = () => { + const { username, code } = useAuth(); + return useQuery(getDigestSubscriptionsQueryOptions(username, code)); +}; + +export const useDigestSubscription = (type: DigestType, target: string) => { + const query = useDigestSubscriptionsQuery(); + return { ...query, subscription: findDigestSubscription(query.data, type, target) }; +}; + +export const useSubscribeDigestMutation = () => { + const { username, code } = useAuth(); + return useSubscribeDigest(username, code); +}; + +export const useLeaveDigestMutation = () => { + const { username, code } = useAuth(); + return useLeaveDigest(username, code); +}; + +export const useUnsubscribeAllDigestsMutation = () => { + const { username, code } = useAuth(); + return useUnsubscribeAllDigests(username, code); +}; diff --git a/src/screens/community/screen/communityScreen.tsx b/src/screens/community/screen/communityScreen.tsx index 72b4daf45f..17546edf70 100644 --- a/src/screens/community/screen/communityScreen.tsx +++ b/src/screens/community/screen/communityScreen.tsx @@ -180,6 +180,24 @@ const CommunityScreen = ({ route }: any) => { isPin onPress={handleNewPostButtonPress} /> + {isLoggedIn && ( + + SheetManager.show(SheetNames.NEWSLETTER_DIGEST, { + payload: { + type: 'community', + target: data.name, + targetLabel: data.title, + }, + }) + } + /> + )} diff --git a/src/screens/editor/container/editorContainer.tsx b/src/screens/editor/container/editorContainer.tsx index 104f5a5d61..4e5b83b9f7 100644 --- a/src/screens/editor/container/editorContainer.tsx +++ b/src/screens/editor/container/editorContainer.tsx @@ -26,6 +26,7 @@ import * as Sentry from '@sentry/react-native'; import Config from 'react-native-config'; import { toastNotification, setRcOffer } from '../../../redux/actions/uiAction'; import { isInsufficientRcError } from '../../../utils/rcError'; +import { maybeOfferFirstPublishDigest } from '../../../utils/firstPublishDigest'; import { getDigitPinCode, shouldPromptPostingAuthority } from '../../../providers/hive/hive'; import { decryptKey } from '../../../utils/crypto'; @@ -1278,6 +1279,16 @@ class EditorContainer extends Component { username: get(currentAccount, 'name'), key: get(currentAccount, 'name'), }); + // Offer the own-notifications email digest once after the FIRST + // publish (post_count is still the pre-publish value here). The + // sheet lives in the global SheetProvider, so it survives the + // editor unmounting; delayed past the navigation transition. + setTimeout(() => { + maybeOfferFirstPublishDigest( + get(currentAccount, 'name'), + get(currentAccount, 'post_count'), + ); + }, 1000); }; if (draftId) { diff --git a/src/screens/emailDigests/index.ts b/src/screens/emailDigests/index.ts new file mode 100644 index 0000000000..fa2a1c5fe1 --- /dev/null +++ b/src/screens/emailDigests/index.ts @@ -0,0 +1,4 @@ +import EmailDigests from './screen/emailDigestsScreen'; + +export { EmailDigests }; +export default EmailDigests; diff --git a/src/screens/emailDigests/screen/emailDigestsScreen.tsx b/src/screens/emailDigests/screen/emailDigestsScreen.tsx new file mode 100644 index 0000000000..e273384c35 --- /dev/null +++ b/src/screens/emailDigests/screen/emailDigestsScreen.tsx @@ -0,0 +1,246 @@ +import React, { useMemo } from 'react'; +import { ActivityIndicator, Alert, ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import { useIntl } from 'react-intl'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { SheetManager } from 'react-native-actions-sheet'; +import EStyleSheet from 'react-native-extended-stylesheet'; +import { DigestSubscription, DigestType } from '@ecency/sdk'; + +import { BasicHeader } from '../../../components'; +import { SheetNames } from '../../../navigation/sheets'; +import { useAppDispatch, useAuth } from '../../../hooks'; +import { toastNotification } from '../../../redux/actions/uiAction'; +import { + findDigestSubscription, + useDigestSubscriptionsQuery, + useUnsubscribeAllDigestsMutation, +} from '../../../providers/queries'; + +/** + * Every email digest subscription of the signed-in account, across all its + * addresses: change cadence or leave one (via the digest sheet), stop all mail + * to one address, and join the own-notifications digest or the Ecency + * newsletter. Reader phase of vision-mobile#3518. + */ +const EmailDigestsScreen = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { username } = useAuth(); + + const subscriptionsQuery = useDigestSubscriptionsQuery(); + const unsubscribeAllMutation = useUnsubscribeAllDigestsMutation(); + + const subscriptions = subscriptionsQuery.data ?? []; + + const byAddress = useMemo(() => { + const groups = new Map(); + subscriptions.forEach((s) => { + const rows = groups.get(s.email) ?? []; + rows.push(s); + groups.set(s.email, rows); + }); + return [...groups.entries()]; + }, [subscriptions]); + + const _rowLabel = (s: DigestSubscription) => { + switch (s.type) { + case 'own': + return intl.formatMessage({ id: 'newsletter.list_own' }); + case 'site': + return intl.formatMessage({ id: 'newsletter.list_site' }); + case 'creator': + return `@${s.target}`; + default: + return s.target; + } + }; + + const _openSheet = (type: DigestType, target: string) => { + SheetManager.show(SheetNames.NEWSLETTER_DIGEST, { payload: { type, target } }); + }; + + const _handleStopAll = (email: string) => { + Alert.alert( + intl.formatMessage({ id: 'newsletter.stop_all_title' }), + intl.formatMessage({ id: 'newsletter.stop_all_body' }, { email }), + [ + { text: intl.formatMessage({ id: 'newsletter.cancel' }), style: 'cancel' }, + { + text: intl.formatMessage({ id: 'newsletter.stop_all_ok' }), + style: 'destructive', + onPress: async () => { + try { + await unsubscribeAllMutation.mutateAsync(email); + dispatch(toastNotification(intl.formatMessage({ id: 'newsletter.stop_all_done' }))); + } catch (err) { + Alert.alert(intl.formatMessage({ id: 'newsletter.fail' })); + } + }, + }, + ], + ); + }; + + const _renderSubscriptionRow = (s: DigestSubscription) => ( + _openSheet(s.type, s.target)}> + + {_rowLabel(s)} + + {`${intl.formatMessage({ id: `newsletter.cadence_${s.cadence}` })} • ${intl.formatMessage( + { + id: `newsletter.status_short_${s.status}`, + defaultMessage: s.status, + }, + )}`} + + + {intl.formatMessage({ id: 'newsletter.manage' })} + + ); + + const _renderAddRow = (labelId: string, type: DigestType, target: string) => ( + _openSheet(type, target)}> + + {intl.formatMessage({ id: labelId })} + + {intl.formatMessage({ id: 'newsletter.subscribe' })} + + ); + + const _renderContent = () => { + if (subscriptionsQuery.isLoading) { + return ; + } + if (subscriptionsQuery.isError) { + const status = (subscriptionsQuery.error as { status?: number })?.status; + return ( + + {intl.formatMessage({ + id: status === 503 ? 'newsletter.unavailable' : 'newsletter.fail', + })} + + ); + } + + const hasOwn = !!username && !!findDigestSubscription(subscriptions, 'own', username); + const hasSite = !!findDigestSubscription(subscriptions, 'site', 'ecency'); + + return ( + + {subscriptions.length === 0 && ( + {intl.formatMessage({ id: 'newsletter.empty' })} + )} + + {byAddress.map(([email, rows]) => ( + + + + {email} + + _handleStopAll(email)}> + + {intl.formatMessage({ id: 'newsletter.stop_all' })} + + + + {rows.map(_renderSubscriptionRow)} + + ))} + + {(!hasOwn || !hasSite) && ( + + + {intl.formatMessage({ id: 'newsletter.discover' })} + + {!hasOwn && !!username && _renderAddRow('newsletter.list_own', 'own', username)} + {!hasSite && _renderAddRow('newsletter.list_site', 'site', 'ecency')} + + )} + + ); + }; + + return ( + + + {_renderContent()} + + ); +}; + +const styles = EStyleSheet.create({ + container: { + flex: 1, + backgroundColor: '$primaryBackgroundColor', + }, + scrollContent: { + padding: 16, + }, + loader: { + marginTop: 32, + }, + emptyText: { + fontSize: 15, + color: '$primaryDarkGray', + textAlign: 'center', + marginTop: 24, + paddingHorizontal: 24, + }, + addressCard: { + backgroundColor: '$primaryLightBackground', + borderRadius: 12, + padding: 12, + marginBottom: 16, + }, + addressHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + paddingBottom: 8, + borderBottomWidth: 1, + borderBottomColor: '$primaryLightGray', + }, + addressText: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: '$primaryBlack', + marginRight: 12, + }, + stopAllText: { + fontSize: 13, + color: '$primaryRed', + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: '$primaryBlack', + marginBottom: 4, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 10, + }, + rowText: { + flex: 1, + marginRight: 12, + }, + rowTitle: { + fontSize: 15, + color: '$primaryBlack', + }, + rowMeta: { + fontSize: 13, + color: '$primaryDarkGray', + marginTop: 2, + }, + rowAction: { + fontSize: 14, + color: '$primaryBlue', + }, +}); + +export default EmailDigestsScreen; diff --git a/src/screens/index.ts b/src/screens/index.ts index 4050f958cb..3a07c0815b 100755 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -30,6 +30,7 @@ import { Community } from './community'; import { CommunityMembers } from './communityMembers'; import { CommunitySettings } from './communitySettings'; import { CommunityActivities } from './communityActivities'; +import { EmailDigests } from './emailDigests'; import Communities from './communities'; import ReferScreen from './referScreen/referScreen'; import AssetDetails from './assetDetails'; @@ -78,6 +79,7 @@ export { CommunityMembers, CommunitySettings, CommunityActivities, + EmailDigests, Communities, WebBrowser, ReferScreen, diff --git a/src/screens/settings/container/settingsContainer.tsx b/src/screens/settings/container/settingsContainer.tsx index 55664e0609..d9e74cdbad 100644 --- a/src/screens/settings/container/settingsContainer.tsx +++ b/src/screens/settings/container/settingsContainer.tsx @@ -599,6 +599,10 @@ class SettingsContainer extends Component { openStoreListing(); break; + case settingsTypes.EMAIL_DIGESTS: + navigation.navigate(ROUTES.SCREENS.EMAIL_DIGESTS); + break; + case settingsTypes.BACKUP_PRIVATE_KEYS: if (isPinCodeOpen) { navigation.navigate(ROUTES.SCREENS.PINCODE, { diff --git a/src/screens/settings/screen/settingsScreen.tsx b/src/screens/settings/screen/settingsScreen.tsx index c76a061e92..1ffdef6677 100644 --- a/src/screens/settings/screen/settingsScreen.tsx +++ b/src/screens/settings/screen/settingsScreen.tsx @@ -478,6 +478,19 @@ const SettingsScreen = ({ actionType="feedback" handleOnButtonPress={handleOnButtonPress} /> + {!!isLoggedIn && ( + + )} {!!isLoggedIn && ( ({ SheetManager: { show: jest.fn() } })); +jest.mock('../navigation/sheets', () => ({ + SheetNames: { NEWSLETTER_DIGEST: 'newsletter_digest' }, +})); +jest.mock('../storage/storage', () => ({ + getItemFromStorage: jest.fn(), + setItemToStorage: jest.fn(), +})); + +// eslint-disable-next-line import/first +import { SheetManager } from 'react-native-actions-sheet'; +// eslint-disable-next-line import/first +import { getItemFromStorage, setItemToStorage } from '../storage/storage'; +// eslint-disable-next-line import/first +import { + firstPublishDigestKey, + maybeOfferFirstPublishDigest, + shouldOfferFirstPublishDigest, +} from './firstPublishDigest'; + +describe('shouldOfferFirstPublishDigest', () => { + it('offers only on a known-zero post count with no prior offer', () => { + expect(shouldOfferFirstPublishDigest(0, false)).toBe(true); + expect(shouldOfferFirstPublishDigest(0, true)).toBe(false); + expect(shouldOfferFirstPublishDigest(3, false)).toBe(false); + // A partial account (post_count not loaded) means "don't know" = no. + expect(shouldOfferFirstPublishDigest(undefined, false)).toBe(false); + expect(shouldOfferFirstPublishDigest(null, false)).toBe(false); + expect(shouldOfferFirstPublishDigest('0', false)).toBe(false); + }); +}); + +describe('maybeOfferFirstPublishDigest', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getItemFromStorage as jest.Mock).mockResolvedValue(null); + (setItemToStorage as jest.Mock).mockResolvedValue(true); + }); + + it('writes the per-username flag BEFORE showing the sheet', async () => { + const order: string[] = []; + (setItemToStorage as jest.Mock).mockImplementation(async () => order.push('flag')); + (SheetManager.show as jest.Mock).mockImplementation(() => order.push('sheet')); + + await maybeOfferFirstPublishDigest('newbie', 0); + + expect(setItemToStorage).toHaveBeenCalledWith( + firstPublishDigestKey('newbie'), + expect.objectContaining({ offeredAt: expect.any(String) }), + ); + expect(SheetManager.show).toHaveBeenCalledWith('newsletter_digest', { + payload: { type: 'own', target: 'newbie', firstPublish: true }, + }); + expect(order).toEqual(['flag', 'sheet']); + }); + + it('never re-prompts once the flag exists, and never prompts past the first post', async () => { + (getItemFromStorage as jest.Mock).mockResolvedValue({ offeredAt: 'x' }); + await maybeOfferFirstPublishDigest('newbie', 0); + (getItemFromStorage as jest.Mock).mockResolvedValue(null); + await maybeOfferFirstPublishDigest('newbie', 5); + await maybeOfferFirstPublishDigest(undefined, 0); + expect(SheetManager.show).not.toHaveBeenCalled(); + expect(setItemToStorage).not.toHaveBeenCalled(); + }); + + it('swallows storage failures rather than surfacing them into the publish flow', async () => { + (getItemFromStorage as jest.Mock).mockRejectedValue(new Error('storage down')); + await expect(maybeOfferFirstPublishDigest('newbie', 0)).resolves.toBeUndefined(); + expect(SheetManager.show).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/firstPublishDigest.ts b/src/utils/firstPublishDigest.ts new file mode 100644 index 0000000000..15f50d32a6 --- /dev/null +++ b/src/utils/firstPublishDigest.ts @@ -0,0 +1,49 @@ +import { SheetManager } from 'react-native-actions-sheet'; +import { SheetNames } from '../navigation/sheets'; +import { getItemFromStorage, setItemToStorage } from '../storage/storage'; + +/** + * One-time own-digest offer after the account's FIRST root publish + * (vision-mobile#3518; web analog gates on post_count === 0 at publish time). + */ +export const firstPublishDigestKey = (username: string) => `first_publish_digest_${username}`; + +/** + * Whether the offer applies. currentAccount can be a truthy-but-partial object + * before the chain account loads, so a missing post_count means "don't know" + * and the answer is no — never prompt on uncertainty. + */ +export const shouldOfferFirstPublishDigest = ( + postCount: unknown, + alreadyOffered: boolean, +): boolean => !alreadyOffered && typeof postCount === 'number' && postCount === 0; + +/** + * Offers the own-notifications digest once, right after the first publish. The + * flag is written BEFORE the sheet shows so a crash or kill can never + * re-prompt; the sheet lives in the global SheetProvider, so it survives the + * editor's navigation.replace. Fire-and-forget: publishing must never fail or + * wait on this. + */ +export const maybeOfferFirstPublishDigest = async ( + username: string | undefined, + postCount: unknown, +): Promise => { + if (!username) { + return; + } + try { + const flag = await getItemFromStorage(firstPublishDigestKey(username)); + if (!shouldOfferFirstPublishDigest(postCount, !!flag)) { + return; + } + await setItemToStorage(firstPublishDigestKey(username), { + offeredAt: new Date().toISOString(), + }); + SheetManager.show(SheetNames.NEWSLETTER_DIGEST, { + payload: { type: 'own', target: username, firstPublish: true }, + }); + } catch (err) { + console.warn('first-publish digest offer failed', err); + } +}; diff --git a/yarn.lock b/yarn.lock index c1d7df5ea7..9e223f6955 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1210,10 +1210,10 @@ url "^0.11.0" xss "^1.0.9" -"@ecency/sdk@^2.3.87": - version "2.3.87" - resolved "https://registry.yarnpkg.com/@ecency/sdk/-/sdk-2.3.87.tgz#1701b80539c79349b34e8a284e9e5c0a8a82a356" - integrity sha512-99QQSqTREAwU5jsyR83aUk/YSkEozkskJEUgXlS6NzPpvZHwDOeN5+i5DDzrpfllt5T9abeT9+Ii7WOU9SPLuw== +"@ecency/sdk@^2.3.93": + version "2.3.93" + resolved "https://registry.yarnpkg.com/@ecency/sdk/-/sdk-2.3.93.tgz#03c18da17be78fe4cc7180e856bb52b3fce9bed2" + integrity sha512-LiHDXdoGW25A2Fu0+VDM2suRZffIzKK5WwZniGptX2CM4BxXoeRtzRXGQa2AKpVsbKZCwI0+fOnZsP4i02pwzQ== dependencies: "@noble/ciphers" "^2.1.1" "@noble/curves" "^2.0.1" From 7f5076669ec0214a9e7feffe530a2a8f58e4aff9 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 25 Aug 2026 10:50:13 +0000 Subject: [PATCH 2/3] fix(newsletter): let the community action row wrap A third content-sized Tag can overflow the non-wrapping row on narrow devices, and CollapsibleCard clips overflow into an untappable button. flexWrap with a row gap stacks the actions instead. --- src/screens/community/screen/communityScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/community/screen/communityScreen.tsx b/src/screens/community/screen/communityScreen.tsx index 17546edf70..d4b6052631 100644 --- a/src/screens/community/screen/communityScreen.tsx +++ b/src/screens/community/screen/communityScreen.tsx @@ -152,7 +152,7 @@ const CommunityScreen = ({ route }: any) => { })}`} - + {isLoggedIn && ( Date: Tue, 25 Aug 2026 10:58:56 +0000 Subject: [PATCH 3/3] fix(newsletter): review round fixes Gate the digest sheet on the subscriptions lookup: undefined data means not known yet, so the form (and its email input) waits for a resolved query and a failed lookup renders the unavailable state instead. Skip the delayed first-publish offer when the active account changed in the window, checked against the live store at fire time and skipped WITHOUT burning the flag. Move the digest mutation wrappers to providers/sdk/mutations per the architecture split. Give the settings row its own Manage label instead of reusing the backup string. --- .../newsletterDigestSheet.tsx | 43 +++++++++++++++++-- src/providers/queries/newsletterQueries.ts | 24 +---------- src/providers/sdk/mutations/index.ts | 7 +++ .../mutations/useNewsletterDigestMutations.ts | 23 ++++++++++ .../screen/emailDigestsScreen.tsx | 7 +-- .../settings/screen/settingsScreen.tsx | 2 +- src/utils/firstPublishDigest.test.ts | 13 ++++++ src/utils/firstPublishDigest.ts | 9 ++++ 8 files changed, 95 insertions(+), 33 deletions(-) create mode 100644 src/providers/sdk/mutations/useNewsletterDigestMutations.ts diff --git a/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx b/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx index 4777e9a0ca..db65777d1f 100644 --- a/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx +++ b/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, Alert, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { useIntl } from 'react-intl'; import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; import EStyleSheet from 'react-native-extended-stylesheet'; @@ -12,9 +12,8 @@ import { findDigestSubscription, knownDigestAddress, useDigestSubscriptionsQuery, - useLeaveDigestMutation, - useSubscribeDigestMutation, } from '../../providers/queries'; +import { useLeaveDigestMutation, useSubscribeDigestMutation } from '../../providers/sdk'; const FALLBACK_SHEET_ID = 'newsletter_digest'; @@ -159,6 +158,39 @@ const NewsletterDigestSheet: React.FC> = ({ shee ); + const _renderBody = () => { + if (checkInboxEmail) { + return _renderCheckInbox(); + } + // The form must not render before the subscriptions lookup resolves: + // `undefined` data means "don't know yet", not "no address on file", and + // showing the email input early would let a second address be submitted + // for an account whose address just hadn't loaded. + if (subscriptionsQuery.isLoading) { + return ( + + + + ); + } + if (subscriptionsQuery.isError) { + return ( + + + {intl.formatMessage({ id: 'newsletter.unavailable' })} + + _close({ cancelled: true })} + text={intl.formatMessage({ id: 'newsletter.cancel' })} + style={styles.cancelButton} + textStyle={styles.cancelButtonText} + /> + + ); + } + return _renderForm(); + }; + const isPendingConfirmation = subscription?.status === 'pending_confirmation'; const isActive = subscription?.status === 'active'; const cadenceUnchanged = isActive && effectiveCadence === subscription?.cadence; @@ -261,7 +293,7 @@ const NewsletterDigestSheet: React.FC> = ({ shee onBeforeShow={_reset} containerStyle={styles.sheetContainer} > - {checkInboxEmail ? _renderCheckInbox() : _renderForm()} + {_renderBody()} ); }; @@ -290,6 +322,9 @@ const styles = EStyleSheet.create({ marginBottom: 16, lineHeight: 22, }, + loader: { + marginVertical: 24, + }, status: { fontSize: 13, color: '$primaryBlue', diff --git a/src/providers/queries/newsletterQueries.ts b/src/providers/queries/newsletterQueries.ts index a8aa214819..a7b31eb5e6 100644 --- a/src/providers/queries/newsletterQueries.ts +++ b/src/providers/queries/newsletterQueries.ts @@ -1,12 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { - DigestSubscription, - DigestType, - getDigestSubscriptionsQueryOptions, - useLeaveDigest, - useSubscribeDigest, - useUnsubscribeAllDigests, -} from '@ecency/sdk'; +import { DigestSubscription, DigestType, getDigestSubscriptionsQueryOptions } from '@ecency/sdk'; import { useAuth } from '../../hooks'; /** @@ -48,18 +41,3 @@ export const useDigestSubscription = (type: DigestType, target: string) => { const query = useDigestSubscriptionsQuery(); return { ...query, subscription: findDigestSubscription(query.data, type, target) }; }; - -export const useSubscribeDigestMutation = () => { - const { username, code } = useAuth(); - return useSubscribeDigest(username, code); -}; - -export const useLeaveDigestMutation = () => { - const { username, code } = useAuth(); - return useLeaveDigest(username, code); -}; - -export const useUnsubscribeAllDigestsMutation = () => { - const { username, code } = useAuth(); - return useUnsubscribeAllDigests(username, code); -}; diff --git a/src/providers/sdk/mutations/index.ts b/src/providers/sdk/mutations/index.ts index 1175aa5630..cea28fe140 100644 --- a/src/providers/sdk/mutations/index.ts +++ b/src/providers/sdk/mutations/index.ts @@ -74,3 +74,10 @@ export { useWitnessProxyMutation } from './useWitnessProxyMutation'; // Market export { useLimitOrderCreateMutation } from './useLimitOrderCreateMutation'; export { useLimitOrderCancelMutation } from './useLimitOrderCancelMutation'; + +// Newsletter (email digests) +export { + useSubscribeDigestMutation, + useLeaveDigestMutation, + useUnsubscribeAllDigestsMutation, +} from './useNewsletterDigestMutations'; diff --git a/src/providers/sdk/mutations/useNewsletterDigestMutations.ts b/src/providers/sdk/mutations/useNewsletterDigestMutations.ts new file mode 100644 index 0000000000..36e69c3dcd --- /dev/null +++ b/src/providers/sdk/mutations/useNewsletterDigestMutations.ts @@ -0,0 +1,23 @@ +import { useLeaveDigest, useSubscribeDigest, useUnsubscribeAllDigests } from '@ecency/sdk'; +import { useAuth } from '../../../hooks'; + +/** + * Email digest mutations (newsletter reader phase, vision-mobile#3518). These + * are REST mutations against the newsletter relay, not broadcasts, so they + * bind the HiveSigner code from useAuth() instead of the platform adapter. + * The SDK hooks keep the shared subscriptions cache in sync on success. + */ +export function useSubscribeDigestMutation() { + const { username, code } = useAuth(); + return useSubscribeDigest(username, code); +} + +export function useLeaveDigestMutation() { + const { username, code } = useAuth(); + return useLeaveDigest(username, code); +} + +export function useUnsubscribeAllDigestsMutation() { + const { username, code } = useAuth(); + return useUnsubscribeAllDigests(username, code); +} diff --git a/src/screens/emailDigests/screen/emailDigestsScreen.tsx b/src/screens/emailDigests/screen/emailDigestsScreen.tsx index e273384c35..fed0dbfa93 100644 --- a/src/screens/emailDigests/screen/emailDigestsScreen.tsx +++ b/src/screens/emailDigests/screen/emailDigestsScreen.tsx @@ -10,11 +10,8 @@ import { BasicHeader } from '../../../components'; import { SheetNames } from '../../../navigation/sheets'; import { useAppDispatch, useAuth } from '../../../hooks'; import { toastNotification } from '../../../redux/actions/uiAction'; -import { - findDigestSubscription, - useDigestSubscriptionsQuery, - useUnsubscribeAllDigestsMutation, -} from '../../../providers/queries'; +import { findDigestSubscription, useDigestSubscriptionsQuery } from '../../../providers/queries'; +import { useUnsubscribeAllDigestsMutation } from '../../../providers/sdk'; /** * Every email digest subscription of the signed-in account, across all its diff --git a/src/screens/settings/screen/settingsScreen.tsx b/src/screens/settings/screen/settingsScreen.tsx index 1ffdef6677..82523a2a83 100644 --- a/src/screens/settings/screen/settingsScreen.tsx +++ b/src/screens/settings/screen/settingsScreen.tsx @@ -484,7 +484,7 @@ const SettingsScreen = ({ id: 'settings.email_digests', })} text={intl.formatMessage({ - id: 'settings.backup', + id: 'newsletter.manage', })} type="button" actionType={settingsTypes.EMAIL_DIGESTS} diff --git a/src/utils/firstPublishDigest.test.ts b/src/utils/firstPublishDigest.test.ts index fbedf4e554..862329f5fd 100644 --- a/src/utils/firstPublishDigest.test.ts +++ b/src/utils/firstPublishDigest.test.ts @@ -8,12 +8,16 @@ jest.mock('../storage/storage', () => ({ getItemFromStorage: jest.fn(), setItemToStorage: jest.fn(), })); +jest.mock('../redux/store/store', () => ({ store: { getState: jest.fn(() => ({})) } })); +jest.mock('../redux/selectors', () => ({ selectCurrentAccount: jest.fn() })); // eslint-disable-next-line import/first import { SheetManager } from 'react-native-actions-sheet'; // eslint-disable-next-line import/first import { getItemFromStorage, setItemToStorage } from '../storage/storage'; // eslint-disable-next-line import/first +import { selectCurrentAccount } from '../redux/selectors'; +// eslint-disable-next-line import/first import { firstPublishDigestKey, maybeOfferFirstPublishDigest, @@ -37,6 +41,7 @@ describe('maybeOfferFirstPublishDigest', () => { jest.clearAllMocks(); (getItemFromStorage as jest.Mock).mockResolvedValue(null); (setItemToStorage as jest.Mock).mockResolvedValue(true); + (selectCurrentAccount as unknown as jest.Mock).mockReturnValue({ name: 'newbie' }); }); it('writes the per-username flag BEFORE showing the sheet', async () => { @@ -66,6 +71,14 @@ describe('maybeOfferFirstPublishDigest', () => { expect(setItemToStorage).not.toHaveBeenCalled(); }); + it('skips silently, without burning the flag, when the account changed during the delay', async () => { + (selectCurrentAccount as unknown as jest.Mock).mockReturnValue({ name: 'someone-else' }); + await maybeOfferFirstPublishDigest('newbie', 0); + expect(SheetManager.show).not.toHaveBeenCalled(); + // Not written: the offer stays available for a later genuine first publish. + expect(setItemToStorage).not.toHaveBeenCalled(); + }); + it('swallows storage failures rather than surfacing them into the publish flow', async () => { (getItemFromStorage as jest.Mock).mockRejectedValue(new Error('storage down')); await expect(maybeOfferFirstPublishDigest('newbie', 0)).resolves.toBeUndefined(); diff --git a/src/utils/firstPublishDigest.ts b/src/utils/firstPublishDigest.ts index 15f50d32a6..deaa68089d 100644 --- a/src/utils/firstPublishDigest.ts +++ b/src/utils/firstPublishDigest.ts @@ -1,6 +1,8 @@ import { SheetManager } from 'react-native-actions-sheet'; import { SheetNames } from '../navigation/sheets'; import { getItemFromStorage, setItemToStorage } from '../storage/storage'; +import { store } from '../redux/store/store'; +import { selectCurrentAccount } from '../redux/selectors'; /** * One-time own-digest offer after the account's FIRST root publish @@ -33,6 +35,13 @@ export const maybeOfferFirstPublishDigest = async ( return; } try { + // The offer fires on a delay after publish; an account switch or logout in + // that window must not open the sheet for the previous username. Checked at + // fire time against the live store, and skipped WITHOUT writing the flag. + const activeName = selectCurrentAccount(store.getState())?.name; + if (activeName !== username) { + return; + } const flag = await getItemFromStorage(firstPublishDigestKey(username)); if (!shouldOfferFirstPublishDigest(postCount, !!flag)) { return;