diff --git a/apps/self-hosted/hosting/docker-compose.yml b/apps/self-hosted/hosting/docker-compose.yml index ddd2300181..47f4cb046c 100644 --- a/apps/self-hosted/hosting/docker-compose.yml +++ b/apps/self-hosted/hosting/docker-compose.yml @@ -10,6 +10,10 @@ services: image: ecency/self-hosted:${TAG:?TAG environment variable is required} container_name: ecency-hosting-blog restart: unless-stopped + # The newsletter subscribe location proxies to the origin vhost on this box; + # host-gateway is what makes host.docker.internal resolve on Linux Docker. + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "127.0.0.1:3100:80" volumes: diff --git a/apps/self-hosted/hosting/nginx-multi-tenant.conf b/apps/self-hosted/hosting/nginx-multi-tenant.conf index 6661e46891..1ad880ad8c 100644 --- a/apps/self-hosted/hosting/nginx-multi-tenant.conf +++ b/apps/self-hosted/hosting/nginx-multi-tenant.conf @@ -123,6 +123,28 @@ server { # Per-tenant static SEO files, written by the hosting API's sync pass. # robots falls back to the generic file baked in the image; sitemap and # rss simply 404 until the first pass writes them. + # Newsletter subscribe (vision-web#1537): the blog's signup form posts here, + # same-origin, and this forwards to the web tier THROUGH THE ORIGIN VHOST ON + # THIS BOX (host.docker.internal, an extra_hosts entry in the compose file), + # deliberately not through public ecency.com: a Cloudflare hop would make + # every tenant's signup look like one datacenter client (one IP for consent + # records, one bucket for rate limits, and a bot challenge would break the + # form outright). The reader's address travels as CF-Connecting-IP, set from + # X-Real-IP, which the HOST's edge vhost asserted from its own remote_addr; + # the origin's /api/ location forwards exactly that header pair to the app. + # Managed instances only, by construction: only they sit behind this nginx. + # Only the subscribe POST is exposed; confirmation and unsubscribe links in + # the mail point at ecency.com directly. + location = /api/newsletter/subscribe { + proxy_pass https://host.docker.internal/api/newsletter/subscribe; + proxy_ssl_server_name on; + proxy_ssl_name eu.ecency.com; + proxy_set_header Host eu.ecency.com; + proxy_set_header CF-Connecting-IP $http_x_real_ip; + proxy_read_timeout 15s; + client_max_body_size 16k; + } + location = /robots.txt { try_files /configs/$tenant_id.robots.txt /robots.txt =404; } @@ -210,6 +232,28 @@ server { } # Same static SEO files, keyed by custom tenant. + # Newsletter subscribe (vision-web#1537): the blog's signup form posts here, + # same-origin, and this forwards to the web tier THROUGH THE ORIGIN VHOST ON + # THIS BOX (host.docker.internal, an extra_hosts entry in the compose file), + # deliberately not through public ecency.com: a Cloudflare hop would make + # every tenant's signup look like one datacenter client (one IP for consent + # records, one bucket for rate limits, and a bot challenge would break the + # form outright). The reader's address travels as CF-Connecting-IP, set from + # X-Real-IP, which the HOST's edge vhost asserted from its own remote_addr; + # the origin's /api/ location forwards exactly that header pair to the app. + # Managed instances only, by construction: only they sit behind this nginx. + # Only the subscribe POST is exposed; confirmation and unsubscribe links in + # the mail point at ecency.com directly. + location = /api/newsletter/subscribe { + proxy_pass https://host.docker.internal/api/newsletter/subscribe; + proxy_ssl_server_name on; + proxy_ssl_name eu.ecency.com; + proxy_set_header Host eu.ecency.com; + proxy_set_header CF-Connecting-IP $http_x_real_ip; + proxy_read_timeout 15s; + client_max_body_size 16k; + } + location = /robots.txt { try_files /configs/$custom_tenant_id.robots.txt /robots.txt =404; } diff --git a/apps/self-hosted/src/core/configuration-loader.ts b/apps/self-hosted/src/core/configuration-loader.ts index 7f40503957..e7b35cc5e5 100644 --- a/apps/self-hosted/src/core/configuration-loader.ts +++ b/apps/self-hosted/src/core/configuration-loader.ts @@ -140,6 +140,8 @@ export interface InstanceConfig { post: { text2Speech: { enabled: boolean }; }; + /** The email-digest signup form (managed instances; the form posts to the host's own /api/newsletter/subscribe). */ + newsletter?: { enabled?: boolean }; tipping?: { enabled?: boolean; general?: { enabled: boolean; buttonLabel?: string }; diff --git a/apps/self-hosted/src/core/i18n-strings.ts b/apps/self-hosted/src/core/i18n-strings.ts index b842c55a90..2f07501cee 100644 --- a/apps/self-hosted/src/core/i18n-strings.ts +++ b/apps/self-hosted/src/core/i18n-strings.ts @@ -300,7 +300,20 @@ export type TranslationKey = | 'panel_configuration_instance_configuration_features_hive_payout_label_description' | 'panel_validation_learn_more_url' | 'panel_validation_create_post_url_community' - | 'panel_validation_create_post_url_refused'; + | 'panel_validation_create_post_url_refused' + | 'newsletterTitle' + | 'newsletterBlurb' + | 'newsletterCommunityBlurb' + | 'newsletterEmail' + | 'newsletterWeekly' + | 'newsletterMonthly' + | 'newsletterCadence' + | 'newsletterSubscribe' + | 'newsletterCheckInbox' + | 'newsletterError' + | 'panel_configuration_instance_configuration_features_newsletter_label' + | 'panel_configuration_instance_configuration_features_newsletter_enabled_label' + | 'panel_configuration_instance_configuration_features_newsletter_enabled_description'; export type Translations = Record; @@ -323,6 +336,19 @@ export const translations: { en: Translations } & Record< Partial > = { en: { + panel_configuration_instance_configuration_features_newsletter_label: 'Newsletter signup', + panel_configuration_instance_configuration_features_newsletter_enabled_label: 'Show the email signup form', + panel_configuration_instance_configuration_features_newsletter_enabled_description: 'Readers can subscribe to a weekly or monthly email digest of this site (managed hosting only; double opt-in).', + newsletterTitle: 'Get new posts by email', + newsletterBlurb: 'A weekly or monthly digest of new posts. Double opt-in, unsubscribe any time.', + newsletterCommunityBlurb: 'The best of this community as a weekly or monthly digest. Double opt-in, unsubscribe any time.', + newsletterEmail: 'Your email', + newsletterWeekly: 'Weekly', + newsletterMonthly: 'Monthly', + newsletterCadence: 'How often', + newsletterSubscribe: 'Subscribe', + newsletterCheckInbox: 'Almost there: confirm from the email we just sent.', + newsletterError: 'Could not subscribe right now. Please try again.', loading: "Loading...", hivesigner_login_failed: 'Sign in could not be completed. Please try again.', loadingPost: "Loading post...", diff --git a/apps/self-hosted/src/features/blog/components/index.ts b/apps/self-hosted/src/features/blog/components/index.ts index fc9b13e637..549063b82b 100644 --- a/apps/self-hosted/src/features/blog/components/index.ts +++ b/apps/self-hosted/src/features/blog/components/index.ts @@ -8,3 +8,4 @@ export * from './blog-post-item'; export * from './blog-post-page'; export * from './blog-posts-list'; export * from './detect-bottom'; +export * from './newsletter-signup'; diff --git a/apps/self-hosted/src/features/blog/components/newsletter-signup.test.tsx b/apps/self-hosted/src/features/blog/components/newsletter-signup.test.tsx new file mode 100644 index 0000000000..c579210c8d --- /dev/null +++ b/apps/self-hosted/src/features/blog/components/newsletter-signup.test.tsx @@ -0,0 +1,433 @@ +// @vitest-environment jsdom + +import type { ReactElement } from 'react'; +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type InstanceConfig, + InstanceConfigManager, +} from '../../../core/configuration-loader'; +import { NewsletterSignup } from './newsletter-signup'; + +/** + * The component half of the signup form (vision-web#1537). The pure rules live + * in newsletter-signup-target.test.ts; this covers what only a rendered + * component can show: the eligibility fence, the request the submit actually + * sends, and the three visible outcomes (busy, done, error). + * + * No test library: React 19's own `act` plus react-dom/client drive it, so the + * app keeps its current devDependencies. + */ + +// React 19 only suppresses its "not wrapped in act(...)" warning when the +// environment flag is set, and `act` itself throws without it. +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +/** A managed, claimed blog: the one shape that offers the form. */ +const MANAGED_BLOG = { + version: 1, + configuration: { + general: { theme: 'system', language: 'en', styles: {} }, + instanceConfiguration: { + type: 'blog', + username: 'Alice', + managed: true, + communityId: '', + meta: { + title: 'Alice Writes', + description: '', + logo: '', + favicon: '', + keywords: '', + }, + layout: { + search: { enabled: true }, + sidebar: { + followers: { enabled: true }, + following: { enabled: true }, + hiveInformation: { enabled: true }, + }, + }, + features: { + postsFilters: ['posts'], + likes: { enabled: true }, + comments: { enabled: true }, + post: { text2Speech: { enabled: false } }, + auth: { enabled: true, methods: [] }, + }, + }, + }, +} as unknown as InstanceConfig; + +/** MANAGED_BLOG with one instanceConfiguration field changed or removed. */ +function configWith( + patch: Record, + drop?: string, +): InstanceConfig { + const next = structuredClone(MANAGED_BLOG) as InstanceConfig; + const instance = next.configuration + .instanceConfiguration as unknown as Record; + Object.assign(instance, patch); + if (drop) delete instance[drop]; + return next; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let container: HTMLDivElement; +let root: Root; +let unmounted = false; + +async function render( + node: ReactElement = , +): Promise { + await act(async () => { + root.render(node); + }); +} + +/** + * Wrapped in act because updateConfig notifies the store's listeners + * synchronously, and an already-mounted component reads it through + * useSyncExternalStore: an unwrapped call re-renders outside act and warns. + */ +async function setConfig(config: InstanceConfig): Promise { + await act(async () => { + InstanceConfigManager.updateConfig(config); + }); +} + +/** + * Set a controlled input the way a keystroke does. Assigning `.value` alone is + * invisible to React: it goes through the prototype setter React has shadowed + * to track the value, so React sees no change and the state never updates. + */ +async function typeInto( + el: HTMLInputElement | HTMLSelectElement | null, + value: string, +): Promise { + if (!el) throw new Error('typeInto: the field is not rendered'); + const prototype = + el instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set; + await act(async () => { + setter?.call(el, value); + // React listens for 'input' on text inputs and 'change' on selects, both at + // the root container, so the event has to bubble. + el.dispatchEvent( + new Event(el instanceof HTMLSelectElement ? 'change' : 'input', { + bubbles: true, + }), + ); + }); +} + +/** + * jsdom implements no form submission, so a submit-button click would only warn + * ("Not implemented"). Dispatching the event React delegates on is the honest + * equivalent: cancelable, because the handler calls preventDefault. + */ +async function submitForm(): Promise { + const form = container.querySelector('form'); + await act(async () => { + form?.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ); + }); +} + +const form = () => container.querySelector('form'); +const emailInput = () => + container.querySelector('input[type="email"]'); +const cadenceSelect = () => + container.querySelector('select'); +const submitButton = () => + container.querySelector('button[type="submit"]'); +// Two regions, because a failure has to interrupt: role=status is the polite +// one (the confirmation), role=alert the assertive one (the failure). +const politeRegion = () => container.querySelector('[role="status"]'); +const errorRegion = () => container.querySelector('[role="alert"]'); + +describe('NewsletterSignup', () => { + beforeEach(() => { + InstanceConfigManager.updateConfig( + structuredClone(MANAGED_BLOG) as InstanceConfig, + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + unmounted = false; + }); + + afterEach(async () => { + if (!unmounted) { + await act(async () => { + root.unmount(); + }); + } + container.remove(); + vi.unstubAllGlobals(); + }); + + it('renders nothing unless the instance is managed, claimed and has the feature on', async () => { + // A true self-host: no managed marker at all. + await setConfig(configWith({}, 'managed')); + await render(); + expect(container.innerHTML).toBe(''); + + // The unclaimed shared template, served as managed but not a tenant. + await setConfig(configWith({ template: true })); + await render(); + expect(container.innerHTML).toBe(''); + + // The owner's own toggle. + await setConfig( + configWith({ + features: { + ...MANAGED_BLOG.configuration.instanceConfiguration.features, + newsletter: { enabled: false }, + }, + }), + ); + await render(); + expect(container.innerHTML).toBe(''); + + // And the eligible instance does render, so the assertions above are not + // passing on a component that never renders anything. + await setConfig(structuredClone(MANAGED_BLOG) as InstanceConfig); + await render(); + expect( + container.querySelector('[data-testid="newsletter-signup"]'), + ).not.toBeNull(); + }); + + it('mounts both live regions empty from the first render', async () => { + await render(); + // A region has to be in the accessibility tree BEFORE its first message, or + // the message is usually not announced (live-region.tsx contract). + expect(politeRegion()?.getAttribute('aria-live')).toBe('polite'); + expect(politeRegion()?.textContent).toBe(''); + // The failure interrupts. Announced politely it waits for a pause, and a + // reader who has moved on never learns the address was not captured. + expect(errorRegion()?.getAttribute('aria-live')).toBe('assertive'); + expect(errorRegion()?.textContent).toBe(''); + }); + + it('names the two form controls differently and asks the browser for the saved address', async () => { + await render(); + // The select used to borrow the button's label, so a reader tabbing the + // form heard "Subscribe, combo box" then "Subscribe, button" and was never + // told what the first control does. + expect(cadenceSelect()?.getAttribute('aria-label')).toBe('How often'); + expect(submitButton()?.textContent).toBe('Subscribe'); + expect(cadenceSelect()?.getAttribute('aria-label')).not.toBe( + submitButton()?.textContent, + ); + expect(emailInput()?.getAttribute('aria-label')).toBe('Your email'); + expect(emailInput()?.getAttribute('autocomplete')).toBe('email'); + }); + + it('posts the subscription, goes busy, then replaces the form with the confirm-your-inbox message', async () => { + const pending = deferred<{ ok: boolean }>(); + // The parameters are declared, unused, so mock.calls is typed and the + // request can be read back without a cast. + const fetchMock = vi.fn( + (_url: string, _init: RequestInit) => pending.promise, + ); + vi.stubGlobal('fetch', fetchMock); + + await render(); + await typeInto(emailInput(), ' reader@example.com '); + await typeInto(cadenceSelect(), 'monthly'); + await submitForm(); + + // In flight: the form is still up and the button says so. `disabled` alone + // is not announced, so aria-busy is what a screen reader user gets. + expect(submitButton()?.disabled).toBe(true); + expect(submitButton()?.getAttribute('aria-busy')).toBe('true'); + expect(form()).not.toBeNull(); + expect(politeRegion()?.textContent).toBe(''); + expect(errorRegion()?.textContent).toBe(''); + + // A second submit while the first is in flight is dropped by the handler. + // The disabled button is only the visible half of that guard: a form can + // still be submitted with the keyboard while its button is disabled. + await submitForm(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/newsletter/subscribe'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(JSON.parse(init.body as string)).toEqual({ + email: 'reader@example.com', + type: 'creator', + target: 'alice', + targetLabel: 'Alice Writes', + cadence: 'monthly', + source: 'self-hosted-blog', + }); + + await act(async () => { + pending.resolve({ ok: true }); + }); + + expect(form()).toBeNull(); + expect(politeRegion()?.textContent).toBe( + 'Almost there: confirm from the email we just sent.', + ); + expect(errorRegion()?.textContent).toBe(''); + }); + + it('keeps the form and shows the error message when the relay answers non-2xx', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 502 })); + vi.stubGlobal('fetch', fetchMock); + + await render(); + await typeInto(emailInput(), 'reader@example.com'); + await submitForm(); + + expect(errorRegion()?.textContent).toBe( + 'Could not subscribe right now. Please try again.', + ); + expect(politeRegion()?.textContent).toBe(''); + // Still submittable: the reader can retry without losing what they typed. + expect(form()).not.toBeNull(); + expect(submitButton()?.disabled).toBe(false); + expect(submitButton()?.getAttribute('aria-busy')).toBe('false'); + expect(emailInput()?.value).toBe('reader@example.com'); + }); + + it('shows the same error when the request never completes', async () => { + // The return type is annotated so the successful retry below can be given + // as an implementation: inferred, the throwing body types it Promise. + const fetchMock = vi.fn(async (): Promise<{ ok: boolean }> => { + throw new TypeError('Failed to fetch'); + }); + vi.stubGlobal('fetch', fetchMock); + + await render(); + await typeInto(emailInput(), 'reader@example.com'); + await submitForm(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(errorRegion()?.textContent).toBe( + 'Could not subscribe right now. Please try again.', + ); + expect(form()).not.toBeNull(); + + // A retry that succeeds clears the error. + fetchMock.mockImplementation(async () => ({ ok: true })); + await submitForm(); + expect(form()).toBeNull(); + expect(politeRegion()?.textContent).toBe( + 'Almost there: confirm from the email we just sent.', + ); + expect(errorRegion()?.textContent).toBe(''); + }); + + it('sends nothing for an empty address', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await render(); + await submitForm(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(form()).not.toBeNull(); + }); + + it('a hive-… instance subscribes to the community digest and reads the community blurb', async () => { + const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({ + ok: true, + })); + vi.stubGlobal('fetch', fetchMock); + await setConfig(configWith({ username: 'hive-125125' })); + + await render(); + expect(container.textContent).toContain('The best of this community'); + + await typeInto(emailInput(), 'reader@example.com'); + await submitForm(); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body as string)).toMatchObject({ + type: 'community', + target: 'hive-125125', + targetLabel: 'Alice Writes', + cadence: 'weekly', + }); + }); + + it('completes a submit under StrictMode, which mounts every effect twice', async () => { + const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({ + ok: true, + })); + vi.stubGlobal('fetch', fetchMock); + + await render( + + + , + ); + await typeInto(emailInput(), 'reader@example.com'); + await submitForm(); + + // The unmount guard is a ref the effect sets back to true on the second + // mount. Drop that one line and StrictMode leaves it false, so in + // development every success is swallowed and the form just sits there. + expect(form()).toBeNull(); + expect(politeRegion()?.textContent).toBe( + 'Almost there: confirm from the email we just sent.', + ); + }); + + it('survives an unmount mid-flight, and does not abort the request', async () => { + const pending = deferred<{ ok: boolean }>(); + const fetchMock = vi.fn( + (_url: string, _init: RequestInit) => pending.promise, + ); + vi.stubGlobal('fetch', fetchMock); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await render(); + await typeInto(emailInput(), 'reader@example.com'); + await submitForm(); + + // The reader follows a link while the request is still open. + await act(async () => { + root.unmount(); + }); + unmounted = true; + await act(async () => { + pending.resolve({ ok: true }); + }); + + // Nothing throws and nothing is logged when the response lands after the + // unmount. React 19 no longer warns about a state update on an unmounted + // component, so this pins the behaviour rather than the warning; the + // `mounted` ref is what keeps it true. And no AbortSignal: the + // subscription the reader already asked for has to complete. An + // AbortController here would cancel real subscriptions at exactly the + // moment readers navigate away, and nothing else would notice. + expect(consoleError).not.toHaveBeenCalled(); + const [, init] = fetchMock.mock.calls[0]; + expect(init.signal).toBeUndefined(); + consoleError.mockRestore(); + }); +}); diff --git a/apps/self-hosted/src/features/blog/components/newsletter-signup.tsx b/apps/self-hosted/src/features/blog/components/newsletter-signup.tsx new file mode 100644 index 0000000000..7d23d4b127 --- /dev/null +++ b/apps/self-hosted/src/features/blog/components/newsletter-signup.tsx @@ -0,0 +1,111 @@ +import { type FormEvent, type ReactElement, useEffect, useRef, useState } from 'react'; +import { InstanceConfigManager } from '../../../core/configuration-loader'; +import { t } from '../../../core/i18n'; +import { LiveRegion } from '../../shared/live-region'; +import { newsletterSignupTarget, newsletterSubscribeBody } from '../utils/newsletter-signup-target'; + +/** + * The email-digest signup form (vision-web#1537). Managed instances only, by + * two fences: the form renders only when the served config carries `managed` + * (never on a true self-host or the unclaimed template), and it posts to the + * host's own `/api/newsletter/subscribe`, a path only the managed nginx + * forwards to ecency.com's public relay. Double opt-in end to end: the service + * answers `pending_confirmation` and the reader confirms from their inbox, so + * the form always says "check your inbox" on success and can learn nothing + * about an address it does not own. + */ +export function NewsletterSignup(): ReactElement | null { + const target = InstanceConfigManager.useConfig(({ configuration }) => + newsletterSignupTarget({ + username: configuration.instanceConfiguration.username, + managed: configuration.instanceConfiguration.managed, + template: configuration.instanceConfiguration.template, + enabled: configuration.instanceConfiguration.features.newsletter?.enabled ?? true, + siteTitle: configuration.instanceConfiguration.meta?.title, + }) + ); + + const [email, setEmail] = useState(''); + const [cadence, setCadence] = useState<'weekly' | 'monthly'>('weekly'); + const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle'); + // The submit awaits a network call; a navigation mid-flight must not update + // an unmounted component. + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + if (!target) return null; + const isCommunity = target.type === 'community'; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (state === 'busy' || !email.trim()) return; + setState('busy'); + try { + const res = await fetch('/api/newsletter/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newsletterSubscribeBody(target, email, cadence)), + }); + if (mounted.current) setState(res.ok ? 'done' : 'error'); + } catch { + if (mounted.current) setState('error'); + } + }; + + return ( +
+

{t('newsletterTitle')}

+

{t(isCommunity ? 'newsletterCommunityBlurb' : 'newsletterBlurb')}

+ {/* Both regions are mounted from the first render, per the live-region + contract: a region that appears at the same moment as its message is + often not announced. Two of them, as community-join-button.tsx does, + because a failure has to interrupt: announced politely, it waits for a + pause and a reader who has moved on never learns the address was not + captured. */} + + + {state !== 'done' && ( +
+ setEmail(e.target.value)} + placeholder={t('newsletterEmail')} + aria-label={t('newsletterEmail')} + className="input-theme w-full text-sm px-2 py-1.5 rounded" + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx b/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx index 3637df4ef7..139934a6dd 100644 --- a/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx +++ b/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx @@ -1,4 +1,5 @@ import { formatMonthYear, InstanceConfigManager, t } from "@/core"; +import { NewsletterSignup } from "../components/newsletter-signup"; import { useAuth } from "@/features/auth"; import { InlineError } from "@/features/shared/inline-error"; import { @@ -103,6 +104,7 @@ function BlogSidebarContent({ username }: { username: string }) { )} + {data && (
@@ -317,7 +319,8 @@ function CommunitySidebar() {
-
+ +
{t("community_info")}
diff --git a/apps/self-hosted/src/features/blog/utils/newsletter-signup-target.test.ts b/apps/self-hosted/src/features/blog/utils/newsletter-signup-target.test.ts new file mode 100644 index 0000000000..cf3bdfa443 --- /dev/null +++ b/apps/self-hosted/src/features/blog/utils/newsletter-signup-target.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { newsletterSignupTarget, newsletterSubscribeBody } from './newsletter-signup-target'; + +describe('newsletterSignupTarget', () => { + const managedBlog = { username: 'Alice', managed: true, siteTitle: 'Alice Writes' }; + + it('offers the form only on a managed, claimed instance with the feature on', () => { + expect(newsletterSignupTarget(managedBlog)).toEqual({ type: 'creator', target: 'alice', targetLabel: 'Alice Writes' }); + // A true self-host never gets it: the config has no managed marker. + expect(newsletterSignupTarget({ username: 'alice' })).toBeNull(); + expect(newsletterSignupTarget({ ...managedBlog, managed: undefined })).toBeNull(); + // The unclaimed template, though served as managed, is not a tenant. + expect(newsletterSignupTarget({ ...managedBlog, template: true })).toBeNull(); + // The owner's toggle. + expect(newsletterSignupTarget({ ...managedBlog, enabled: false })).toBeNull(); + expect(newsletterSignupTarget({ ...managedBlog, enabled: true })).not.toBeNull(); + expect(newsletterSignupTarget({ managed: true })).toBeNull(); + }); + + it('a hive-… instance subscribes to the community digest; labels fall back to the handle', () => { + expect(newsletterSignupTarget({ username: 'hive-125125', managed: true, siteTitle: 'Town Square' })).toEqual({ + type: 'community', + target: 'hive-125125', + targetLabel: 'Town Square', + }); + expect(newsletterSignupTarget({ username: 'HIVE-125125', managed: true, siteTitle: ' ' })).toEqual({ + type: 'community', + target: 'hive-125125', + targetLabel: 'hive-125125', + }); + expect(newsletterSignupTarget({ username: 'bob', managed: true })).toEqual({ type: 'creator', target: 'bob', targetLabel: '@bob' }); + }); + + it('builds the exact relay body, source self-hosted-blog', () => { + const t = newsletterSignupTarget(managedBlog)!; + expect(newsletterSubscribeBody(t, ' reader@example.com ', 'monthly')).toEqual({ + email: 'reader@example.com', + type: 'creator', + target: 'alice', + targetLabel: 'Alice Writes', + cadence: 'monthly', + source: 'self-hosted-blog', + }); + }); +}); diff --git a/apps/self-hosted/src/features/blog/utils/newsletter-signup-target.ts b/apps/self-hosted/src/features/blog/utils/newsletter-signup-target.ts new file mode 100644 index 0000000000..aca80683e4 --- /dev/null +++ b/apps/self-hosted/src/features/blog/utils/newsletter-signup-target.ts @@ -0,0 +1,55 @@ +/** + * The pure half of the newsletter signup form (vision-web#1537): whether this + * instance offers it and what a submission subscribes to. Kept out of the + * component so the rules are testable in the node test environment. + * + * Managed instances only: the form renders only when the served config carries + * `managed` (never a true self-host, never the unclaimed template), and its + * POST goes to the host's own /api/newsletter/subscribe, a path only the + * managed nginx forwards to ecency.com's public relay. + */ +export interface NewsletterSignupTarget { + type: 'creator' | 'community'; + target: string; + targetLabel: string; +} + +const COMMUNITY_RE = /^hive-\d+$/i; + +export function newsletterSignupTarget(cfg: { + username?: string; + managed?: boolean; + template?: boolean; + enabled?: boolean; + siteTitle?: string; +}): NewsletterSignupTarget | null { + if (cfg.managed !== true || cfg.template === true || cfg.enabled === false || !cfg.username) return null; + const target = cfg.username.toLowerCase(); + const isCommunity = COMMUNITY_RE.test(target); + return { + type: isCommunity ? 'community' : 'creator', + target, + targetLabel: cfg.siteTitle?.trim() || (isCommunity ? target : `@${target}`), + }; +} + +export interface NewsletterSubscribeBody { + email: string; + type: 'creator' | 'community'; + target: string; + targetLabel: string; + cadence: 'weekly' | 'monthly'; + source: 'self-hosted-blog'; +} + +/** The body the form posts, exactly as the relay expects it. */ +export function newsletterSubscribeBody(t: NewsletterSignupTarget, email: string, cadence: 'weekly' | 'monthly'): NewsletterSubscribeBody { + return { + email: email.trim(), + type: t.type, + target: t.target, + targetLabel: t.targetLabel, + cadence, + source: 'self-hosted-blog', + }; +} diff --git a/apps/self-hosted/src/features/floating-menu/config-fields.ts b/apps/self-hosted/src/features/floating-menu/config-fields.ts index 2760742083..458a4298bd 100644 --- a/apps/self-hosted/src/features/floating-menu/config-fields.ts +++ b/apps/self-hosted/src/features/floating-menu/config-fields.ts @@ -410,6 +410,19 @@ export function buildConfigFields( type: 'array', description: t('panel_configuration_instance_configuration_features_posts_filters_description'), }, + newsletter: { + label: t('panel_configuration_instance_configuration_features_newsletter_label'), + type: 'section', + fields: { + enabled: { + label: t('panel_configuration_instance_configuration_features_newsletter_enabled_label'), + type: 'boolean', + // The runtime treats an absent value as ON; the editor must show the same. + default: true, + description: t('panel_configuration_instance_configuration_features_newsletter_enabled_description'), + }, + }, + }, likes: { label: t('panel_configuration_instance_configuration_features_likes_label'), type: 'section', diff --git a/apps/self-hosted/vitest.config.ts b/apps/self-hosted/vitest.config.ts index 3a0dc0b872..425bd6a3d3 100644 --- a/apps/self-hosted/vitest.config.ts +++ b/apps/self-hosted/vitest.config.ts @@ -35,7 +35,10 @@ export default defineConfig({ test: { environment: 'node', globals: true, - include: ['src/**/*.test.ts'], + // .tsx too: component tests render through react-dom/client, and while + // this matched only .ts they were collected as zero files, so the suite + // reported green with the component's whole spec never executed. + include: ['src/**/*.test.{ts,tsx}'], alias: { // Absolute, because Vite resolves an alias target relative to the // importing file rather than the project root. As './src' this alias diff --git a/apps/web/src/app/api/newsletter/subscribe/route.ts b/apps/web/src/app/api/newsletter/subscribe/route.ts index 7194248eb3..44d5773895 100644 --- a/apps/web/src/app/api/newsletter/subscribe/route.ts +++ b/apps/web/src/app/api/newsletter/subscribe/route.ts @@ -1,6 +1,5 @@ import { NextRequest } from "next/server"; import { resolveUser, unauthorizedResponse } from "@/app/api/threespeak/resolve-user"; -import { isProRosterMember } from "@/server/pro-members"; import { callNewsletter, clientIp, @@ -12,7 +11,7 @@ import { const TYPES = new Set(["own", "community", "creator", "site"]); const CADENCES = new Set(["weekly", "monthly"]); -const SOURCES = new Set(["community-page", "creator-page", "settings", "landing-page", "publish-prompt", "post-page"]); +const SOURCES = new Set(["community-page", "creator-page", "settings", "landing-page", "publish-prompt", "post-page", "self-hosted-blog"]); /** * Subscribe to a community or creator digest. @@ -23,8 +22,11 @@ const SOURCES = new Set(["community-page", "creator-page", "settings", "landing- * as one action, and what allows an opted-out address to be re-confirmed. The account is * taken from the verified token, never from the body. * - * Creator digests are offered only for Ecency Pro creators, checked here against the - * roster on the server: the button on the profile is a convenience, this is the gate. + * Every creator is offered a digest (decided 2026-08-19), so this route checks no + * entitlement at all: a reader may subscribe to any account, and the automatic issues go + * out for any list that has readers. Ecency Pro gates the active capabilities instead, + * sending a chosen post and composing an issue, which server/newsletter-sender-gate checks + * on the send routes. * The site digest (`type: "site"`, the homepage form) has one target, `ecency`, which the * service enforces. */ @@ -58,15 +60,8 @@ export async function POST(request: NextRequest) { if (target !== account) return Response.json({ error: "Invalid request" }, { status: 400 }); } - if (type === "creator") { - const isPro = await isProRosterMember(target); - if (isPro === null) { - return Response.json({ error: "Authorization service unavailable" }, { status: 503 }); - } - if (!isPro) { - return Response.json({ error: "Creator digests are available for Ecency Pro creators" }, { status: 403 }); - } - } + // Where the creator eligibility check used to be. There is none now: every + // creator is offered a digest, see the note at the top of this file. const upstream = await callNewsletter("/api/subscriptions", { method: "POST", diff --git a/apps/web/src/config/config.template.ts b/apps/web/src/config/config.template.ts index e3ed03dd72..f568a32920 100644 --- a/apps/web/src/config/config.template.ts +++ b/apps/web/src/config/config.template.ts @@ -67,11 +67,12 @@ const CONFIG = { enabled: true }, newsletter: { - // Kill switch for the digest subscribe controls (community pages, Pro creator profiles, - // settings, homepage form, first-publish prompt). Whether the feature actually shows is - // decided at request time from NEWSLETTER_API_URL + NEWSLETTER_SERVICE_TOKEN (see - // server/newsletter-internal `newsletterFeatureEnabled`): one image serves every - // region, so a build-time flag cannot express "only where the service is configured". + // Kill switch for the digest subscribe controls (community pages, creator profiles, + // settings, homepage form, first-publish prompt, end-of-post prompt). Whether the + // feature actually shows is decided at request time from NEWSLETTER_API_URL + + // NEWSLETTER_SERVICE_TOKEN (see server/newsletter-internal `newsletterFeatureEnabled`): + // one image serves every region, so a build-time flag cannot express "only where the + // service is configured". // Set NEXT_PUBLIC_NEWSLETTER_ENABLED=0 to force the controls off on a configured deploy. enabled: process.env.NEXT_PUBLIC_NEWSLETTER_ENABLED !== "0" }, diff --git a/apps/web/src/config/config.ts b/apps/web/src/config/config.ts index 45ffd2120e..07d2788a8b 100644 --- a/apps/web/src/config/config.ts +++ b/apps/web/src/config/config.ts @@ -66,11 +66,12 @@ const CONFIG = { enabled: true }, newsletter: { - // Kill switch for the digest subscribe controls (community pages, Pro creator profiles, - // settings, homepage form, first-publish prompt). Whether the feature actually shows is - // decided at request time from NEWSLETTER_API_URL + NEWSLETTER_SERVICE_TOKEN (see - // server/newsletter-internal `newsletterFeatureEnabled`): one image serves every - // region, so a build-time flag cannot express "only where the service is configured". + // Kill switch for the digest subscribe controls (community pages, creator profiles, + // settings, homepage form, first-publish prompt, end-of-post prompt). Whether the + // feature actually shows is decided at request time from NEWSLETTER_API_URL + + // NEWSLETTER_SERVICE_TOKEN (see server/newsletter-internal `newsletterFeatureEnabled`): + // one image serves every region, so a build-time flag cannot express "only where the + // service is configured". // Set NEXT_PUBLIC_NEWSLETTER_ENABLED=0 to force the controls off on a configured deploy. enabled: process.env.NEXT_PUBLIC_NEWSLETTER_ENABLED !== "0" }, diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 83e9ca6baf..49b4926134 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -4143,7 +4143,6 @@ "status-suppressed": "This address is not receiving Ecency email at the moment.", "status-pending-short": "confirm your email", "status-suppressed-short": "paused", - "error-not-pro": "Creator digests are available for Ecency Pro creators.", "error-unavailable": "The digest service is not available right now. Please try again in a moment.", "error-generic": "Something went wrong. Please try again.", "settings-title": "Email digests", diff --git a/apps/web/src/features/newsletter/digest-subscribe-button.tsx b/apps/web/src/features/newsletter/digest-subscribe-button.tsx index 3677fbe47d..ad63de1c68 100644 --- a/apps/web/src/features/newsletter/digest-subscribe-button.tsx +++ b/apps/web/src/features/newsletter/digest-subscribe-button.tsx @@ -1,8 +1,5 @@ "use client"; -import { isProMember } from "@/features/pro"; -import { getProMembersQueryOptions } from "@ecency/sdk"; -import { useQuery } from "@tanstack/react-query"; import { UilEnvelope, UilEnvelopeCheck } from "@tooni/iconscout-unicons-react"; import { Button, ButtonProps } from "@ui/button"; import i18next from "i18next"; @@ -26,10 +23,10 @@ interface Props { * The entry point on a community page or a creator profile. Shows the current state when * the logged-in account holds a subscription, opens the dialog for everything else. * Renders nothing when the feature is off (call sites also wrap it in NewsletterGate, - * the runtime counterpart of EcencyConfigManager.Conditional; this is the belt to that brace), - * and, for creators, when the creator is not an Ecency Pro member: creator lists are a - * Pro capability, and the server enforces the same rule, this only avoids offering - * something the request would refuse. + * the runtime counterpart of EcencyConfigManager.Conditional; this is the belt to that brace). + * Every creator is offered a list (decided 2026-08-19), so no per-account eligibility is + * looked up here and whether the button appears is known on the first render. Ecency Pro + * gates sending a post to a list and composing an issue, never subscribing to one. */ /** * A shared subscribe link (?subscribe=digest, vision-web#1537) opens the dialog @@ -38,13 +35,12 @@ interface Props { * in sync with the router), so it needs neither the app router nor a Suspense * boundary; a button rendered outside a Next page still works. */ -function useSubscribeLinkOpener(onOpen: (() => void) | null | undefined): void { +function useSubscribeLinkOpener(onOpen: (() => void) | null): void { const opened = useRef(false); useEffect(() => { - // undefined: not known yet whether this list is offered here (the Pro - // roster is still loading) — do nothing, touch nothing, try again on the - // next render. null: known not offered — leave the link alone. A function: - // open once and clean the URL. + // null when the button is not shown here: leave the link alone and touch + // nothing, the reader may be on a page that carries no list. A function: + // open once, then clean the URL. if (!onOpen || opened.current || typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); if (params.get(SUBSCRIBE_PARAM) !== SUBSCRIBE_PARAM_VALUE) return; @@ -60,13 +56,11 @@ export function DigestSubscribeButton({ type, target, targetLabel, source, size, const enabled = useNewsletterEnabled(); const [open, setOpen] = useState(false); const { subscription } = useDigestSubscription(type, target); - const proQuery = useQuery({ ...getProMembersQueryOptions(), enabled: enabled && type === "creator" }); - const pro = proQuery.data; - const offered = enabled && (type !== "creator" || isProMember(pro?.members, target)); - // For a creator list, "offered" is unknown until the roster has answered. - const eligibilityKnown = type !== "creator" || !enabled || proQuery.isSuccess || proQuery.isError; + // Every creator has a list, so being offered one is just the feature flag, + // which is why the shared-link opener can act on the first render. + const offered = enabled; const openFromLink = useCallback(() => setOpen(true), []); - useSubscribeLinkOpener(!eligibilityKnown ? undefined : offered ? openFromLink : null); + useSubscribeLinkOpener(offered ? openFromLink : null); if (!offered) return null; diff --git a/apps/web/src/features/newsletter/digest-subscribe-dialog.tsx b/apps/web/src/features/newsletter/digest-subscribe-dialog.tsx index e53e5156d8..4256155dc9 100644 --- a/apps/web/src/features/newsletter/digest-subscribe-dialog.tsx +++ b/apps/web/src/features/newsletter/digest-subscribe-dialog.tsx @@ -119,13 +119,11 @@ export function DigestSubscribeDialog({ type, target, targetLabel, source, show, } } catch (e) { const message = - e instanceof NewsletterApiError && e.status === 403 - ? i18next.t("newsletter.error-not-pro") - : e instanceof NewsletterApiError && e.status === 503 - ? i18next.t("newsletter.error-unavailable") - : e instanceof NewsletterApiError && (e.status === 502 || e.status === 504) - ? i18next.t("newsletter.error-gateway") - : i18next.t("newsletter.error-generic"); + e instanceof NewsletterApiError && e.status === 503 + ? i18next.t("newsletter.error-unavailable") + : e instanceof NewsletterApiError && (e.status === 502 || e.status === 504) + ? i18next.t("newsletter.error-gateway") + : i18next.t("newsletter.error-generic"); toastError(message); } }; diff --git a/apps/web/src/features/newsletter/post-subscribe-prompt.tsx b/apps/web/src/features/newsletter/post-subscribe-prompt.tsx index da5584638e..6d5c31ca2b 100644 --- a/apps/web/src/features/newsletter/post-subscribe-prompt.tsx +++ b/apps/web/src/features/newsletter/post-subscribe-prompt.tsx @@ -1,10 +1,9 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { getCommunityQueryOptions, getProMembersQueryOptions } from "@ecency/sdk"; +import { getCommunityQueryOptions } from "@ecency/sdk"; import type { Entry } from "@/entities"; import { useActiveAccount } from "@/core/hooks/use-active-account"; -import { isProMember } from "@/features/pro/pro-config"; import { Button } from "@ui/button"; import { UilEnvelope } from "@tooni/iconscout-unicons-react"; import i18next from "i18next"; @@ -16,7 +15,7 @@ import type { DigestType } from "./types"; /** * At the end of a post (vision-web#1537): a reader who is signed in and not yet - * subscribed is offered the author's digest (when the author is Pro), or the + * subscribed is offered the author's digest (every creator has one since 2026-08-19), or the * community's digest when the post was made in one. Dismissible per list; the * dismissal is remembered on this device. Never shown to the author for their * own list, never twice for the same list, never while a subscription exists. @@ -29,18 +28,14 @@ export function PostSubscribePrompt({ entry, communityTitle, className }: { entr const me = activeUser?.username?.toLowerCase(); const isTopLevel = !entry.parent_author && (entry.depth ?? 0) === 0; const inCommunity = /^hive-\d+$/.test(entry.category); - const proQuery = useQuery({ ...getProMembersQueryOptions(), enabled: enabled && !!me && isTopLevel }); - // Until the roster has answered, no list is offered: deciding "not Pro" from - // a still-loading roster would offer the community's list to a reader who - // should get the author's. A failed roster falls back to the community's. - const rosterKnown = proQuery.isSuccess || proQuery.isError; - const authorIsPro = isProMember(proQuery.data?.members, entry.author); - - // Which list to offer: the author's own when they are Pro, else the community's. + // Which list to offer: the author's digest is open to every creator + // (2026-08-19), so it is offered first; the community's digest when the + // reader IS the author (their own list is not offered to them) and the post + // was made in a community. const list: { type: "creator" | "community"; target: string } | null = - !enabled || !me || !isTopLevel || !rosterKnown + !enabled || !me || !isTopLevel ? null - : authorIsPro && me !== entry.author + : me !== entry.author ? { type: "creator", target: entry.author } : inCommunity ? { type: "community", target: entry.category } diff --git a/apps/web/src/server/newsletter-internal.ts b/apps/web/src/server/newsletter-internal.ts index ba958ed172..8452b711ad 100644 --- a/apps/web/src/server/newsletter-internal.ts +++ b/apps/web/src/server/newsletter-internal.ts @@ -5,8 +5,10 @@ * route handler here, which holds the service token, establishes identity where identity * matters (HiveSigner token verified upstream), and relays. That keeps the service private, * keeps its token off the client, and lets this side add what only it knows: the caller's - * IP and user agent, and server-side entitlement checks (a creator digest is only offered - * for Ecency Pro creators). + * IP and user agent, plus the entitlement checks that are ours to make (Ecency Pro to send + * a chosen post, community roles to send a community's issues, see + * server/newsletter-sender-gate). Subscribing itself is entitlement-free: every creator is + * offered a digest. * * Mirrors server/hosting-internal.ts. */ diff --git a/apps/web/src/specs/api/newsletter-subscribe-route.spec.ts b/apps/web/src/specs/api/newsletter-subscribe-route.spec.ts index e8ce33c957..349b5c111c 100644 --- a/apps/web/src/specs/api/newsletter-subscribe-route.spec.ts +++ b/apps/web/src/specs/api/newsletter-subscribe-route.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /** * The subscribe relay: the browser never talks to the newsletter service. This handler - * verifies identity when a token is present, enforces the Pro gate for creator digests on + * verifies identity when a token is present, keeps creator digests open to every creator on * the server, adds what only the server knows (IP, user agent), and relays. */ const mocks = vi.hoisted(() => ({ @@ -107,19 +107,11 @@ describe("POST /api/newsletter/subscribe", () => { expect(mocks.fetch).not.toHaveBeenCalled(); }); - it("offers creator digests only for Ecency Pro creators, checked on the server", async () => { - mocks.isPro.mockResolvedValueOnce(false); - const denied = await post({ ...VALID, type: "creator", target: "someone" }); - expect(denied.status).toBe(403); - expect(mocks.fetch).not.toHaveBeenCalled(); - - mocks.isPro.mockResolvedValueOnce(null); - expect((await post({ ...VALID, type: "creator", target: "someone" })).status).toBe(503); - - mocks.isPro.mockResolvedValueOnce(true); + it("offers creator digests for EVERY creator: no Pro roster is consulted (2026-08-19)", async () => { mocks.fetch.mockResolvedValue(upstream(200, { status: "pending_confirmation" })); - expect((await post({ ...VALID, type: "creator", target: "Good-Karma" })).status).toBe(200); - expect(mocks.isPro).toHaveBeenLastCalledWith("good-karma"); + expect((await post({ ...VALID, type: "creator", target: "Someone" })).status).toBe(200); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body).target).toBe("someone"); + expect(mocks.isPro).not.toHaveBeenCalled(); }); it("accepts the site digest from the landing page and relays it without a Pro check", async () => { diff --git a/apps/web/src/specs/features/newsletter/digest-subscribe.spec.tsx b/apps/web/src/specs/features/newsletter/digest-subscribe.spec.tsx index f8907fa801..f191d86039 100644 --- a/apps/web/src/specs/features/newsletter/digest-subscribe.spec.tsx +++ b/apps/web/src/specs/features/newsletter/digest-subscribe.spec.tsx @@ -242,19 +242,14 @@ describe("DigestSubscribeButton", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("offers a creator digest only for an Ecency Pro creator", () => { + it("offers a creator digest for every creator: no Pro roster involved (2026-08-19)", () => { const client = createTestQueryClient(); - client.setQueryData(["accounts", "pro-members"], { members: ["good-karma"] }); - const { container: notPro } = renderConfigured( - , - { queryClient: client } - ); - expect(notPro).toBeEmptyDOMElement(); renderConfigured( - , + , { queryClient: client } ); expect(screen.getByRole("button", { name: /newsletter\.button/ })).toBeInTheDocument(); + expect(client.getQueryData(["accounts", "pro-members"])).toBeUndefined(); }); it("reflects the logged-in account's subscription and opens the dialog", async () => { diff --git a/apps/web/src/specs/features/newsletter/list-building.spec.tsx b/apps/web/src/specs/features/newsletter/list-building.spec.tsx index 7f97946041..e05cfb2405 100644 --- a/apps/web/src/specs/features/newsletter/list-building.spec.tsx +++ b/apps/web/src/specs/features/newsletter/list-building.spec.tsx @@ -13,13 +13,10 @@ vi.mock("@/config", () => ({ getConfigValue: (fn: (c: unknown) => unknown) => fn({ visionFeatures: { newsletter: { enabled: flags.newsletter } } }) } })); -// The roster is answered by `roster.fn` so a test can hold it pending; the -// community query answers with a title. Seeded roster data stays (staleTime is -// Infinity in the test client), so seeding tests are unaffected. -const roster = vi.hoisted(() => ({ fn: vi.fn(() => new Promise<{ members: string[] }>(() => {})) })); +// None of these components looks up an entitlement: the community query is the +// only SDK call they make, and it answers with a title. vi.mock("@ecency/sdk", async () => ({ ...(await vi.importActual("@ecency/sdk")), - getProMembersQueryOptions: () => ({ queryKey: ["accounts", "pro-members"], queryFn: () => roster.fn() }), getCommunityQueryOptions: (name: string) => ({ queryKey: ["community", name], queryFn: async () => ({ name, title: "Town Square", team: [] }) }) })); vi.mock("@/utils", async () => ({ @@ -108,7 +105,6 @@ describe("list building (vision-web#1537)", () => { window.history.replaceState(null, "", "/@alice?subscribe=digest&x=1"); fetchMock.mockReturnValue(json(200, { subscriptions: [] })); const client = createTestQueryClient(); - client.setQueryData(["accounts", "pro-members"], { members: ["alice"] }); render(, client); // The dialog is open, and the parameter is gone while the rest of the query is kept. await waitFor(() => expect(window.location.search).toBe("?x=1")); @@ -116,33 +112,24 @@ describe("list building (vision-web#1537)", () => { await waitFor(() => expect(within(dialog).getByText("newsletter.intro-creator")).toBeInTheDocument()); }); - it("a shared creator link survives a roster that answers after mount: nothing is consumed until eligibility is known", async () => { - window.history.replaceState(null, "", "/@alice?subscribe=digest"); + it("a shared creator link opens for any creator at once: no roster involved (2026-08-19)", async () => { + window.history.replaceState(null, "", "/@bob?subscribe=digest"); fetchMock.mockReturnValue(json(200, { subscriptions: [] })); const client = createTestQueryClient(); - let resolveRoster!: (v: { members: string[] }) => void; - roster.fn.mockImplementationOnce(() => new Promise<{ members: string[] }>((r) => (resolveRoster = r))); - render(, client); - await new Promise((r) => setTimeout(r, 50)); - // Roster still pending: the parameter is untouched and no dialog is open. - expect(window.location.search).toBe("?subscribe=digest"); - expect(document.querySelector("#modal-dialog-container")?.textContent ?? "").toBe(""); - resolveRoster({ members: ["alice"] }); + render(, client); await waitFor(() => expect(window.location.search).toBe("")); const dialog = document.querySelector("#modal-dialog-container") as HTMLElement; await waitFor(() => expect(within(dialog).getByText("newsletter.intro-creator")).toBeInTheDocument()); - // A creator who turns out not to be Pro: the link is left alone, nothing opens. - window.history.replaceState(null, "", "/@bob?subscribe=digest"); - const client2 = createTestQueryClient(); - client2.setQueryData(["accounts", "pro-members"], { members: ["someone-else"] }); - render(, client2); + // With the feature off, the link is left alone. + window.history.replaceState(null, "", "/@carol?subscribe=digest"); + flags.newsletter = false; + render(, createTestQueryClient()); await new Promise((r) => setTimeout(r, 50)); expect(window.location.search).toBe("?subscribe=digest"); }); it("at the end of a post, offers the author's digest to a signed-in reader who is not subscribed; remembers 'Not now'; nothing for the author, a subscriber, or when signed out", async () => { const client = createTestQueryClient(); - client.setQueryData(["accounts", "pro-members"], { members: ["bob"] }); client.setQueryData(digestSubscriptionsKey("alice"), []); const entry = mockEntry({ author: "bob", permlink: "hello", category: "photography", parent_author: "", depth: 0 }); window.localStorage.clear(); @@ -180,24 +167,10 @@ describe("list building (vision-web#1537)", () => { expect(c4.textContent).toBe(""); }); - it("offers nothing while the Pro roster is still loading, so a Pro author's reader is not steered to the community list", async () => { - const client = createTestQueryClient(); - client.setQueryData(digestSubscriptionsKey("alice"), []); - let resolveRoster!: (v: { members: string[] }) => void; - roster.fn.mockImplementationOnce(() => new Promise<{ members: string[] }>((r) => (resolveRoster = r))); - const entry = mockEntry({ author: "bob", permlink: "p", category: "hive-125125", parent_author: "", depth: 0 }); - render(, client); - await new Promise((r) => setTimeout(r, 50)); - expect(screen.queryByRole("region")).toBeNull(); - resolveRoster({ members: ["bob"] }); - await screen.findByRole("region", { name: "newsletter.post-prompt-title" }); - expect(screen.getByText("newsletter.post-prompt-body-creator")).toBeInTheDocument(); - }); - - it("falls back to the community's digest for a post made in a community when the author is not Pro", async () => { + it("offers the community's digest when the author reads their own post in a community; the title comes from the community query", async () => { + loggedIn("bob"); const client = createTestQueryClient(); - client.setQueryData(["accounts", "pro-members"], { members: [] }); - client.setQueryData(digestSubscriptionsKey("alice"), []); + client.setQueryData(digestSubscriptionsKey("bob"), []); window.localStorage.clear(); const entry = mockEntry({ author: "bob", permlink: "p", category: "hive-125125", parent_author: "", depth: 0 }); const { unmount: u0 } = render(, client); @@ -210,13 +183,13 @@ describe("list building (vision-web#1537)", () => { await waitFor(() => expect(within(dialog).getByText("newsletter.intro-community")).toBeInTheDocument()); // While the dialog is open, a subscription appearing (the refetch after subscribing) hides // the card but keeps the dialog, so its "check your inbox" outcome is not lost. - client.setQueryData(digestSubscriptionsKey("alice"), [{ id: "1", type: "community", target: "hive-125125", cadence: "weekly", status: "pending_confirmation", email: "a@e.com" }]); + client.setQueryData(digestSubscriptionsKey("bob"), [{ id: "1", type: "community", target: "hive-125125", cadence: "weekly", status: "pending_confirmation", email: "a@e.com" }]); await new Promise((r) => setTimeout(r, 30)); expect(screen.queryByRole("region")).toBeNull(); // Still mounted, now showing the pending state the dialog reads from the fresh subscription. expect(within(dialog).getByText("newsletter.status-pending")).toBeInTheDocument(); u0(); - client.setQueryData(digestSubscriptionsKey("alice"), []); + client.setQueryData(digestSubscriptionsKey("bob"), []); // A comment gets no prompt. const comment = mockEntry({ author: "bob", permlink: "re", category: "hive-125125", parent_author: "x", depth: 1 }); const { container } = render(, client);