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..db65777d1f --- /dev/null +++ b/src/components/newsletterDigestSheet/newsletterDigestSheet.tsx @@ -0,0 +1,389 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +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'; +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, +} from '../../providers/queries'; +import { useLeaveDigestMutation, useSubscribeDigestMutation } from '../../providers/sdk'; + +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 _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; + + 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 ( + + {_renderBody()} + + ); +}; + +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, + }, + loader: { + marginVertical: 24, + }, + 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..a7b31eb5e6 --- /dev/null +++ b/src/providers/queries/newsletterQueries.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { DigestSubscription, DigestType, getDigestSubscriptionsQueryOptions } 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) }; +}; 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/community/screen/communityScreen.tsx b/src/screens/community/screen/communityScreen.tsx index 72b4daf45f..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 && ( { 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..fed0dbfa93 --- /dev/null +++ b/src/screens/emailDigests/screen/emailDigestsScreen.tsx @@ -0,0 +1,243 @@ +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 } from '../../../providers/queries'; +import { useUnsubscribeAllDigestsMutation } from '../../../providers/sdk'; + +/** + * 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..82523a2a83 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(), +})); +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, + 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); + (selectCurrentAccount as unknown as jest.Mock).mockReturnValue({ name: 'newbie' }); + }); + + 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('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(); + expect(SheetManager.show).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/firstPublishDigest.ts b/src/utils/firstPublishDigest.ts new file mode 100644 index 0000000000..deaa68089d --- /dev/null +++ b/src/utils/firstPublishDigest.ts @@ -0,0 +1,58 @@ +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 + * (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 { + // 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; + } + 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"