diff --git a/.changeset/easy-wasps-appear.md b/.changeset/easy-wasps-appear.md new file mode 100644 index 00000000000..57ee67be712 --- /dev/null +++ b/.changeset/easy-wasps-appear.md @@ -0,0 +1,13 @@ +--- +"@tryghost/kg-utils": none +"@tryghost/kg-unsplash-selector": none +"@tryghost/kg-markdown-html-renderer": none +"@tryghost/kg-html-to-lexical": none +"@tryghost/kg-default-transforms": none +"@tryghost/kg-default-cards": none +"@tryghost/kg-converters": none +"@tryghost/kg-clean-basic-html": none +"@tryghost/kg-card-factory": none +--- + +Switched lint config to formatter-neutral rules; no runtime change diff --git a/.github/renovate.json5 b/.github/renovate.json5 index efb3cb1d336..2e1792a83e8 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -45,6 +45,13 @@ // We have to disable platform based automerge (forcing renovate to do it manually) // as otherwise renovate wont follow our schedule "platformAutomerge": false, + // Merge automerged PRs by rebasing the branch's own commits onto the base + // instead of squashing. Renovate branches are a single, concise commit + // (e.g. "chore(deps): update X to Y"), so a squash merge would replace that + // clean message with the PR title *and* the full PR body — release-note + // tables, changelogs, dependency dashboards — producing a giant commit + // message on main. Rebase preserves the branch commit message verbatim. + "automergeStrategy": "rebase", // Branch protection does not require up-to-date branches (the required // status checks ruleset has strict: false), so a green-but-behind branch // is still mergeable. Rebasing on every `main` commit therefore buys diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29141787ce3..f8733703013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1039,15 +1039,30 @@ jobs: # which depends on Arial's font metrics # - playwright firefox deps: system media codecs so firefox can decode # the H.264 fixtures in the video card tests + # msttcorefonts downloads the font files from SourceForge at install time; + # mirror-redirect roulette there can hang indefinitely. timeout-minutes is + # the backstop, and each network op is bounded + retried so a stuck fetch + # fails fast and re-rolls onto a different mirror instead of sitting. - name: Install Koenig editor test dependencies (fonts + media codecs) if: matrix.app == '@tryghost/koenig-lexical' + timeout-minutes: 10 env: DEBIAN_FRONTEND: noninteractive DEBCONF_NONINTERACTIVE_SEEN: "true" run: | sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections" - sudo apt-get update -yq - sudo apt-get install -yq msttcorefonts + sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 + installed= + for i in 1 2 3; do + if sudo timeout -k 5 120 apt-get install -yq \ + -o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then + installed=1; break + fi + echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5 + done + if [ -z "$installed" ]; then + echo "msttcorefonts failed after 3 attempts"; exit 1 + fi pnpm exec playwright install-deps firefox - name: Run Playwright tests @@ -1464,7 +1479,11 @@ jobs: tags: ${{ steps.meta-core.outputs.tags }} labels: ${{ steps.meta-core.outputs.labels }} cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main - cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-core-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # PRs read cache-main but never write cache: the old cache-pr-N was + # write-only (cache-from is always cache-main) and mode=max cache export + # is the heaviest GHCR push in the run — skipping it on PRs cuts push + # volume with no rebuild cost. Only main/tag publish cache-main. + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-core-name) || '' }} # Uploaded here, before the full image is even built: on the artifact path # consumers (Ghost-Moya CD) need the core image — server only, no admin — @@ -1535,7 +1554,8 @@ jobs: tags: ${{ steps.meta-full.outputs.tags }} labels: ${{ steps.meta-full.outputs.labels }} cache-from: type=registry,ref=${{ steps.strategy.outputs.image-full-name }}:cache-main - cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-full-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # See core image above: PRs skip cache-to; only main/tag publish cache-main. + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-full-name) || '' }} # The production image artifact is saved before the e2e steps below: # Ghost-Moya CD discovers `docker-image-production` by name in this run @@ -1711,7 +1731,8 @@ jobs: tags: ${{ steps.meta-e2e.outputs.tags }} labels: ${{ steps.meta-e2e.outputs.labels }} cache-from: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-main', steps.strategy.outputs.image-e2e-name) || '' }} - cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-e2e-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + # See core image above: PRs skip cache-to; only main/tag publish cache-main. + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-e2e-name) || '' }} - name: Save E2E image as artifact if: steps.strategy.outputs.use-artifact == 'true' diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts index 0c541e9f32f..7fe442480d7 100644 --- a/apps/activitypub/src/api/activitypub.test.ts +++ b/apps/activitypub/src/api/activitypub.test.ts @@ -157,6 +157,153 @@ describe('ActivityPubAPI', function () { }); }); + describe('getPreferences', function () { + test('It returns the sensitive media display preference', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(init?.method).toEqual('GET'); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + showSensitiveMedia: true + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.getPreferences()).resolves.toEqual({ + showSensitiveMedia: true + }); + }); + + test('It defaults showSensitiveMedia to false when it is missing', async function () { + const fakeFetch = Fetch({ + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + response: JSONResponse({}) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.getPreferences()).resolves.toEqual({ + showSensitiveMedia: false + }); + }); + }); + + describe('updatePreferences', function () { + test('It saves the sensitive media display preference', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(init?.method).toEqual('PUT'); + expect(headers.get('Authorization')).toContain('fake-token'); + expect(headers.get('Content-Type')).toEqual('application/json'); + expect(init?.body).toEqual(JSON.stringify({ + showSensitiveMedia: true + })); + }, + response: JSONResponse({ + showSensitiveMedia: true + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({ + showSensitiveMedia: true + }); + }); + + test('It can disable the sensitive media display preference', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + expect(init?.method).toEqual('PUT'); + expect(init?.body).toEqual(JSON.stringify({ + showSensitiveMedia: false + })); + }, + response: JSONResponse({ + showSensitiveMedia: false + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.updatePreferences({showSensitiveMedia: false})).resolves.toEqual({ + showSensitiveMedia: false + }); + }); + + test('It preserves the submitted preference when the update response has no body', async function () { + const fakeFetch = Fetch({ + 'https://activitypub.api/.ghost/activitypub/v1/preferences': { + async assert(_resource, init) { + expect(init?.method).toEqual('PUT'); + expect(init?.body).toEqual(JSON.stringify({ + showSensitiveMedia: true + })); + }, + response: new Response(null, {status: 204}) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await expect(api.updatePreferences({showSensitiveMedia: true})).resolves.toEqual({ + showSensitiveMedia: true + }); + }); + }); + describe('follow', function () { test('It passes the token to the follow endpoint', async function () { const fakeFetch = Fetch({ @@ -1356,7 +1503,22 @@ describe('ActivityPubAPI', function () { notifications: [ { id: 'https://example.com/notifications/abc123', - type: 'like' + type: 'like', + post: { + id: 'https://example.com/posts/sensitive', + type: 'note', + title: null, + content: '

Sensitive post

', + url: 'https://example.com/posts/sensitive', + sensitive: true, + contentWarning: 'Sensitive topic', + likeCount: 0, + likedByMe: false, + repostCount: 0, + repostedByMe: false, + replyCount: 0, + attachments: [] + } }, { id: 'https://example.com/notifications/def456', @@ -1380,7 +1542,22 @@ describe('ActivityPubAPI', function () { expect(actual.notifications).toEqual([ { id: 'https://example.com/notifications/abc123', - type: 'like' + type: 'like', + post: { + id: 'https://example.com/posts/sensitive', + type: 'note', + title: null, + content: '

Sensitive post

', + url: 'https://example.com/posts/sensitive', + sensitive: true, + contentWarning: 'Sensitive topic', + likeCount: 0, + likedByMe: false, + repostCount: 0, + repostedByMe: false, + replyCount: 0, + attachments: [] + } }, { id: 'https://example.com/notifications/def456', diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts index 8f6cd6a23c4..ece9ed9a4d4 100644 --- a/apps/activitypub/src/api/activitypub.ts +++ b/apps/activitypub/src/api/activitypub.ts @@ -159,6 +159,8 @@ export interface Notification { title: string | null; content: string; url: string; + sensitive?: boolean; + contentWarning?: string | null; likeCount: number; likedByMe: boolean; repostCount: number; @@ -177,6 +179,8 @@ export interface Notification { title: string | null; content: string; url: string; + sensitive?: boolean; + contentWarning?: string | null; }, createdAt: string; } @@ -206,6 +210,23 @@ export interface SocialWebDomain { actorUrl: string; } +export interface Preferences { + showSensitiveMedia: boolean; +} + +/** + * Fails closed: anything the server doesn't explicitly confirm leaves sensitive + * media hidden. + */ +function parsePreferences(json: object | null): Preferences { + return { + showSensitiveMedia: + json !== null && + 'showSensitiveMedia' in json && + json.showSensitiveMedia === true + }; +} + export const PostType = { Note: 0, Article: 1, @@ -220,6 +241,8 @@ export interface Post { title: string; excerpt: string; summary: string | null; + sensitive?: boolean; + contentWarning?: string | null; content: string; url: string; featureImageUrl: string | null; @@ -684,6 +707,26 @@ export class ActivityPubAPI { return {count}; } + async getPreferences(): Promise { + const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl); + + return parsePreferences(await this.fetchJSON(url)); + } + + async updatePreferences(preferences: Preferences): Promise { + const url = new URL('.ghost/activitypub/v1/preferences', this.apiUrl); + const json = await this.fetchJSON(url, 'PUT', preferences); + + // A body-less success means the write was accepted but not echoed back, + // so the submitted value is what's now stored. Parsing null here would + // silently report the preference as disabled. + if (json === null) { + return preferences; + } + + return parsePreferences(json); + } + async resetNotificationsCount() { const url = new URL('.ghost/activitypub/v1/notifications/unread/reset', this.apiUrl); diff --git a/apps/activitypub/src/components/feed/feed-item.tsx b/apps/activitypub/src/components/feed/feed-item.tsx index 5aea3212cb2..e28f06ad5ea 100644 --- a/apps/activitypub/src/components/feed/feed-item.tsx +++ b/apps/activitypub/src/components/feed/feed-item.tsx @@ -2,7 +2,7 @@ import FeedItemMenu from './feed-item-menu'; import React, {useEffect, useRef, useState} from 'react'; import {ActivityPubAttachment, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Skeleton} from '@tryghost/shade/components'; -import {H4} from '@tryghost/shade/primitives'; +import {H4, Text} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {toast} from 'sonner'; @@ -19,8 +19,16 @@ import {openLinksInNewTab, sanitizeHtml, stripHtml} from '../../utils/content-fo import {renderTimestamp} from '../../utils/render-timestamp'; import {useDeleteMutationForUser, useFollowMutationForUser, useUnfollowMutationForUser} from '../../hooks/use-activity-pub-queries'; import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path'; +import {useSensitiveMediaDisclosure} from '@src/hooks/use-sensitive-media-disclosure'; -export function getAttachment(object: ObjectProperties): ActivityPubAttachment | ActivityPubAttachment[] | null { +/** + * The subset of an object the attachment helpers below actually read. Keeping it + * narrow lets callers that only have media (such as notification previews) pass + * what they have without inventing a whole ObjectProperties. + */ +export type AttachmentSource = Pick; + +export function getAttachment(object: AttachmentSource): ActivityPubAttachment | ActivityPubAttachment[] | null { let attachment: ActivityPubAttachment | ActivityPubAttachment[] | undefined; if (object.image) { @@ -55,7 +63,7 @@ export function getAttachment(object: ObjectProperties): ActivityPubAttachment | } export function renderFeedAttachment( - object: ObjectProperties, + object: AttachmentSource, onImageClick?: (url: string) => void, brokenImages?: Set, onImageError?: (url: string) => void @@ -178,6 +186,179 @@ export function renderFeedAttachment( } } +export function SensitiveMediaOverlay({ + className = '', + isLayered = false, + size = 'default', + showLabel = true, + onReveal +}: { + className?: string; + isLayered?: boolean; + size?: 'default' | 'compact'; + showLabel?: boolean; + onReveal: (event: React.MouseEvent) => void; +}) { + const isCompact = size === 'compact'; + + return ( +
event.stopPropagation()} + > +
+ {isCompact ? ( +
+
+ ) : ( +
+
+ )} +
+ ); +} + +export function SensitiveMediaHideButton({ + label = 'Hide media', + layout = 'overlay', + onHide +}: { + label?: string; + layout?: 'overlay' | 'inline'; + onHide: (event: React.MouseEvent) => void; +}) { + return ( + + ); +} + +export function ContentWarningOverlay({ + className = '', + isLayered = false, + label, + size = 'default', + onReveal +}: { + className?: string; + isLayered?: boolean; + label: string; + size?: 'default' | 'compact'; + onReveal: (event: React.MouseEvent) => void; +}) { + const isCompact = size === 'compact'; + + return ( +
event.stopPropagation()} + > +
+ {isCompact ? ( +
+
+ ) : ( +
+
+ )} +
+ ); +} + function renderInboxAttachment(object: ObjectProperties, isLoading: boolean | undefined) { const attachment = getAttachment(object); @@ -293,6 +474,25 @@ const FeedItem: React.FC = ({ const deleteMutation = useDeleteMutationForUser('index'); const navigate = useNavigateWithBasePath(); + const { + contentWarning, + shouldHideContentWarning, + shouldHideSensitiveMedia, + canHideSensitiveMedia, + isContentWarningRevealed, + showContentWarningOverlay, + contentWarningMinHeight, + contentWarningWrapperRef, + revealSensitiveMedia, + hideSensitiveMedia, + revealContentWarning + } = useSensitiveMediaDisclosure({ + contentWarning: object?.contentWarning, + sensitive: object?.sensitive, + hasMedia: getAttachment(object) !== null, + resetKey: object?.id + }); + const followMutation = useFollowMutationForUser( 'index', () => { @@ -381,6 +581,132 @@ const FeedItem: React.FC = ({ setBrokenImages(prev => new Set(prev).add(url)); }; + const renderFeedMedia = (mediaClickHandler?: (url: string) => void) => { + if (shouldHideSensitiveMedia) { + const media = renderFeedAttachment(object, undefined, brokenImages, handleImageError); + + if (!media) { + return ; + } + + const mediaWrapperClassName = clsx( + 'relative mt-3 overflow-hidden rounded-md [&>.attachment-gallery]:mt-0 [&>img]:mt-0 [&>img]:block', + Array.isArray(getAttachment(object)) ? 'w-full' : 'w-fit max-w-full' + ); + + return ( +
+ {media} + +
+ ); + } + + const media = renderFeedAttachment(object, mediaClickHandler, brokenImages, handleImageError); + + if (!media) { + return null; + } + + if (canHideSensitiveMedia) { + const mediaWrapperClassName = clsx( + 'relative mt-3 [&>.attachment-gallery]:mt-0 [&>img]:mt-0 [&>img]:block', + Array.isArray(getAttachment(object)) ? 'w-full' : 'w-fit max-w-full' + ); + + return ( +
+ {media} + +
+ ); + } + + return media; + }; + + const renderContentWarningOverlay = (isLayered = false) => { + if (!contentWarning) { + return null; + } + + return ( + + ); + }; + + const renderNoteContent = (options?: { + contentClassName?: string; + mediaClickHandler?: (url: string) => void; + showName?: boolean; + }) => { + const { + contentClassName = 'ap-note-content break-anywhere line-clamp-[10] leading-[1.4285714286] tracking-[-0.006em] text-pretty text-gray-900 dark:text-gray-300 [&_p+p]:mt-3', + mediaClickHandler = openLightbox, + showName = false + } = options ?? {}; + + return ( + <> + {showName && object.name && ( +

{object.name}

+ )} +
+ {!isLoading ? +
{ + const target = e.target as HTMLElement; + if ( + target.tagName === 'A' || + target.closest('a') + ) { + e.stopPropagation(); + } + }} + /> + : + + } +
+ {isTruncated && ( + + )} + {renderFeedMedia(mediaClickHandler)} + + ); + }; + + const renderNoteContentWithWarning = (options?: { + contentClassName?: string; + mediaClickHandler?: (url: string) => void; + showName?: boolean; + }) => { + if (!contentWarning) { + return renderNoteContent(options); + } + + return ( +
+ {isContentWarningRevealed && renderNoteContent(options)} + {showContentWarningOverlay && renderContentWarningOverlay(isContentWarningRevealed)} +
+ ); + }; + let author = actor; if (type === 'Announce') { author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor; @@ -487,37 +813,18 @@ const FeedItem: React.FC = ({
- {(object.type === 'Article') ?
- {renderFeedAttachment(object, onClick, brokenImages, handleImageError)} -
-
{object.name}
-
{object.preview?.content}
-
-
: -
-
- {!isLoading ? -
{ - const target = e.target as HTMLElement; - if ( - target.tagName === 'A' || - target.closest('a') - ) { - e.stopPropagation(); - } - }} - /> - : - - } + {(object.type === 'Article') ? ( + shouldHideContentWarning ? renderContentWarningOverlay() : +
+ {renderFeedMedia(onClick)} +
+
{object.name}
+
{object.preview?.content}
+
- {isTruncated && ( - - )} - {renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)} + ) : +
+ {renderNoteContentWithWarning()}
}
@@ -577,9 +884,10 @@ const FeedItem: React.FC = ({ }
- {object.name &&

{object.name}

} -
- {renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)} + {renderNoteContentWithWarning({ + contentClassName: 'ap-note-content-large break-anywhere text-[1.6rem] tracking-[-0.011em] text-pretty text-gray-900 dark:text-gray-300 [&_p+p]:mt-3', + showName: true + })}
{showStats && = ({
- {(object.type === 'Article') && renderFeedAttachment(object, onClick, brokenImages, handleImageError)} - {object.name &&

{object.name}

} - {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} - {(object.type === 'Note') && renderFeedAttachment(object, openLightbox, brokenImages, handleImageError)} - {(object.type === 'Article') && } + {shouldHideContentWarning ? renderContentWarningOverlay() : <> + {(object.type === 'Article') && renderFeedMedia(onClick)} + {object.name &&

{object.name}

} + {(object.preview && object.type === 'Article') ?
{object.preview.content}
:
} + {(object.type === 'Note') && renderFeedMedia(openLightbox)} + {(object.type === 'Article') && } + } {!isCompact &&
{showStats && = ({
-

- {isLoading ? : (object.name ? object.name : ( - - ))} -

-
- {!isLoading ? -
- : - - } -
- - {!isLoading ? (object.content && `${getReadingTime(object.content)}`) : } - + {shouldHideContentWarning ? renderContentWarningOverlay() : <> +

+ {isLoading ? : (object.name ? object.name : ( + + ))} +

+
+ {!isLoading ? +
+ : + + } +
+ + {!isLoading ? (object.content && `${getReadingTime(object.content)}`) : } + + }
{showStats && = ({
- {renderInboxAttachment(object, isLoading)} + {shouldHideContentWarning ? null : shouldHideSensitiveMedia ? ( + + ) : renderInboxAttachment(object, isLoading)}
)} diff --git a/apps/activitypub/src/hooks/use-activity-pub-queries.ts b/apps/activitypub/src/hooks/use-activity-pub-queries.ts index 6550d048dd0..cd7bf63102f 100644 --- a/apps/activitypub/src/hooks/use-activity-pub-queries.ts +++ b/apps/activitypub/src/hooks/use-activity-pub-queries.ts @@ -9,6 +9,7 @@ import { type GetAccountFollowsResponse, type Notification, type Post, + type Preferences, type ReplyChainResponse, type SearchResults, type SocialWebDomain, @@ -95,6 +96,7 @@ const QUERY_KEYS = { postsLikedByAccount: ['account_liked_posts'], notifications: (handle: string) => ['notifications', handle], notificationsCount: (handle: string) => ['notifications_count', handle], + preferences: ['preferences'], blockedAccounts: (handle: string) => ['blocked_accounts', handle], blockedDomains: (handle: string) => ['blocked_domains', handle], topics: () => ['topics'] @@ -1784,6 +1786,61 @@ export function useFeedForUser(options: {enabled: boolean}) { return {feedQuery, updateFeedActivity}; } +export function usePreferencesForUser() { + return useQuery({ + queryKey: QUERY_KEYS.preferences, + // Preferences are owner/admin only, so a failure here is usually a 403 + // that won't resolve by asking again. Callers fall back to hiding + // sensitive media. + retry: false, + async queryFn() { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI('index', siteUrl); + + return api.getPreferences(); + } + }); +} + +export function useUpdatePreferencesForUser({onError}: {onError?: () => void} = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + scope: {id: 'preferences'}, + async mutationFn(preferences: Preferences) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI('index', siteUrl); + + return api.updatePreferences(preferences); + }, + async onMutate(preferences) { + await queryClient.cancelQueries({ + queryKey: QUERY_KEYS.preferences + }); + + const previousPreferences = queryClient.getQueryData(QUERY_KEYS.preferences); + + queryClient.setQueryData(QUERY_KEYS.preferences, preferences); + + return {previousPreferences}; + }, + onError(_error, _preferences, context) { + if (context?.previousPreferences === undefined) { + // There was nothing to roll back to, so drop the optimistic + // value rather than leaving showSensitiveMedia: true cached. + queryClient.removeQueries({queryKey: QUERY_KEYS.preferences}); + } else { + queryClient.setQueryData(QUERY_KEYS.preferences, context.previousPreferences); + } + + onError?.(); + }, + onSuccess(preferences) { + queryClient.setQueryData(QUERY_KEYS.preferences, preferences); + } + }); +} + export function useInboxForUser(options: {enabled: boolean}) { const queryKey = QUERY_KEYS.inbox; const queryClient = useQueryClient(); diff --git a/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts b/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts new file mode 100644 index 00000000000..c992016221e --- /dev/null +++ b/apps/activitypub/src/hooks/use-sensitive-media-disclosure.ts @@ -0,0 +1,121 @@ +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {usePreferencesForUser} from '@hooks/use-activity-pub-queries'; + +interface SensitiveMediaDisclosureOptions { + contentWarning?: string | null; + sensitive?: boolean; + /** + * Whether the post actually carries media worth concealing. Each surface + * detects this differently — the feed looks at attachments, the reader also + * considers the feature image and media embedded in the article body. + */ + hasMedia: boolean; + /** + * Resets the disclosure state when it changes, so a revealed post never + * leaks its reveal into the next post rendered by the same component. + */ + resetKey?: string; +} + +interface SensitiveMediaDisclosure { + contentWarning: string | null; + shouldHideContentWarning: boolean; + shouldHideSensitiveMedia: boolean; + canHideSensitiveMedia: boolean; + isContentWarningRevealed: boolean; + showContentWarningOverlay: boolean; + contentWarningMinHeight: number | undefined; + contentWarningWrapperRef: React.RefObject; + revealSensitiveMedia: (event: React.MouseEvent) => void; + hideSensitiveMedia: (event: React.MouseEvent) => void; + revealContentWarning: (event: React.MouseEvent) => void; +} + +/** + * Owns the reveal/hide state for sensitive media and content warnings. + * + * Both disclosures fail closed: until the preferences query resolves, and + * whenever it fails, sensitive media stays hidden. + * + * A content warning covers the whole post, so it also covers any media inside + * it. Revealing the warning therefore reveals the media too, rather than + * stacking a second warning on top of the first. + */ +export function useSensitiveMediaDisclosure({ + contentWarning: rawContentWarning, + sensitive, + hasMedia, + resetKey +}: SensitiveMediaDisclosureOptions): SensitiveMediaDisclosure { + const {data: preferences} = usePreferencesForUser(); + const showSensitiveMediaByDefault = preferences?.showSensitiveMedia ?? false; + + const [isSensitiveMediaRevealed, setIsSensitiveMediaRevealed] = useState(false); + const [isSensitiveMediaManuallyHidden, setIsSensitiveMediaManuallyHidden] = useState(false); + const [isContentWarningRevealed, setIsContentWarningRevealed] = useState(false); + const [showContentWarningOverlay, setShowContentWarningOverlay] = useState(true); + const [contentWarningMinHeight, setContentWarningMinHeight] = useState(undefined); + const contentWarningWrapperRef = useRef(null); + + useEffect(() => { + setIsSensitiveMediaRevealed(false); + setIsSensitiveMediaManuallyHidden(false); + setIsContentWarningRevealed(false); + setShowContentWarningOverlay(true); + setContentWarningMinHeight(undefined); + }, [resetKey]); + + useLayoutEffect(() => { + if (!isContentWarningRevealed || !showContentWarningOverlay) { + return; + } + + // Content is mounted under the overlay; remove the overlay before paint + // so the browser never paints an empty intermediate frame. + setShowContentWarningOverlay(false); + setContentWarningMinHeight(undefined); + }, [isContentWarningRevealed, showContentWarningOverlay]); + + const contentWarning = rawContentWarning?.trim() || null; + const hasContentWarning = contentWarning !== null; + const hasSensitiveMedia = sensitive === true && hasMedia; + const isSensitiveMediaGated = hasSensitiveMedia && !hasContentWarning && !showSensitiveMediaByDefault; + const shouldHideSensitiveMedia = isSensitiveMediaGated && (isSensitiveMediaManuallyHidden || !isSensitiveMediaRevealed); + + const revealSensitiveMedia = (event: React.MouseEvent) => { + event.stopPropagation(); + setIsSensitiveMediaManuallyHidden(false); + setIsSensitiveMediaRevealed(true); + }; + + const hideSensitiveMedia = (event: React.MouseEvent) => { + event.stopPropagation(); + setIsSensitiveMediaManuallyHidden(true); + setIsSensitiveMediaRevealed(false); + }; + + const revealContentWarning = (event: React.MouseEvent) => { + event.stopPropagation(); + // Pin the collapsed height so the post doesn't jump while the revealed + // content mounts underneath the overlay. + const height = contentWarningWrapperRef.current?.offsetHeight; + if (height) { + setContentWarningMinHeight(height); + } + setIsContentWarningRevealed(true); + }; + + return { + contentWarning, + shouldHideContentWarning: hasContentWarning && !isContentWarningRevealed, + shouldHideSensitiveMedia, + canHideSensitiveMedia: isSensitiveMediaGated && !shouldHideSensitiveMedia, + isContentWarningRevealed, + showContentWarningOverlay, + contentWarningMinHeight, + contentWarningWrapperRef, + revealSensitiveMedia, + hideSensitiveMedia, + revealContentWarning + }; +} diff --git a/apps/activitypub/src/utils/posts.ts b/apps/activitypub/src/utils/posts.ts index 721aef0d030..8e039c4d85a 100644 --- a/apps/activitypub/src/utils/posts.ts +++ b/apps/activitypub/src/utils/posts.ts @@ -94,6 +94,8 @@ export function mapPostToActivity(post: Post): Activity { name: post.title, content: post.content, summary: post.summary, + sensitive: post.sensitive, + contentWarning: post.contentWarning, url: post.url, attributedTo: actor, image: post.featureImageUrl ?? '', diff --git a/apps/activitypub/src/views/inbox/components/customizer.tsx b/apps/activitypub/src/views/inbox/components/customizer.tsx index bb2e70daf27..c860c29d39c 100644 --- a/apps/activitypub/src/views/inbox/components/customizer.tsx +++ b/apps/activitypub/src/views/inbox/components/customizer.tsx @@ -161,7 +161,7 @@ const CustomizerView: React.FC = ({ }) => ( - @@ -208,6 +208,7 @@ const CustomizerView: React.FC = ({
} + {errors.code ? {errors.code} : overrides.code.value !== '' &&
{homepageUrl}{overrides.code.value}
}
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx rename to apps/admin/src/settings/growth/offers/edit-offer-modal.tsx index d71bc1bbd8a..0ba5e7c992a 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx @@ -1,4 +1,4 @@ -import PortalFrame from '@/settings/app/components/settings/membership/portal/portal-frame'; +import PortalFrame from '@/settings/membership/portal/portal-frame'; import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Textarea} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -55,7 +55,7 @@ const Sidebar: React.FC<{ setTimeout(() => setIsCopied(false), 2000); }; - const confirmStatusChange = async () => { + const confirmStatusChange = () => { if (offer?.status === 'active') { confirm({ title: 'Archive offer', @@ -139,7 +139,7 @@ const Sidebar: React.FC<{ Offer code updateOffer({code: e.target.value})} onKeyDown={() => clearError('code')} /> - {offer?.code !== '' && {isCopied ? 'Copied!' : 'Copy link'}} + {offer?.code !== '' && void handleCopyClick()}>{isCopied ? 'Copied!' : 'Copy link'}} {errors.code ? {errors.code} : offer?.code !== '' && {homepageUrl}{offer?.code}} diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx rename to apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx index 12ff2606b38..c593d544af0 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/edit-retention-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx @@ -1,4 +1,4 @@ -import PortalFrame from '@/settings/app/components/settings/membership/portal/portal-frame'; +import PortalFrame from '@/settings/membership/portal/portal-frame'; import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea} from '@tryghost/shade/components'; @@ -358,7 +358,7 @@ const RetentionOfferSidebar: React.FC<{ ); }; -const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { +const EditRetentionOfferModal: React.FC<{cadence: 'monthly' | 'yearly'}> = ({cadence}) => { const {updateRoute} = useSettingsNavigation(); const {siteData} = useGlobalData(); const {data: {tiers = []} = {}} = useBrowseTiers(); @@ -367,7 +367,6 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { const {mutateAsync: editOffer} = useEditOffer(); const invalidateOffers = useInvalidateOffers(); const [href, setHref] = useState(''); - const cadence = id === 'monthly' ? 'monthly' : 'yearly' as const; const breadcrumbTitle = cadence === 'monthly' ? 'Monthly retention' : 'Yearly retention'; const offerCadence = cadence === 'monthly' ? 'month' : 'year'; const activePaidTiers = getPaidActiveTiers(tiers || []); @@ -540,7 +539,7 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { }); const activeRetentionOfferId = editableRetentionOffer?.id || 'none'; - const currentOfferKey = `${id}:${activeRetentionOfferId}`; + const currentOfferKey = `${cadence}:${activeRetentionOfferId}`; useEffect(() => { if (!hasFetchedOffers || isFetchingOffers || saveState === 'unsaved' || initializedOfferKey === currentOfferKey) { diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts b/apps/admin/src/settings/growth/offers/offer-helpers.test.ts similarity index 96% rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts rename to apps/admin/src/settings/growth/offers/offer-helpers.test.ts index cfc54c60f3d..ce631aabe02 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.test.ts +++ b/apps/admin/src/settings/growth/offers/offer-helpers.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {MAX_RETENTION_OFFER_NAME_LENGTH, generateRetentionOfferName} from '@/settings/app/components/settings/growth/offers/offer-helpers'; +import {MAX_RETENTION_OFFER_NAME_LENGTH, generateRetentionOfferName} from '@/settings/growth/offers/offer-helpers'; describe('generateRetentionOfferName', function () { it('keeps the full 8-character hash when the preferred wording fits', function () { diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts b/apps/admin/src/settings/growth/offers/offer-helpers.ts similarity index 53% rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts rename to apps/admin/src/settings/growth/offers/offer-helpers.ts index fcfa651cdb8..f626814a868 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-helpers.ts +++ b/apps/admin/src/settings/growth/offers/offer-helpers.ts @@ -1,3 +1,6 @@ +import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; +import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; const MAX_RETENTION_OFFER_NAME_LENGTH = 40; @@ -64,3 +67,53 @@ export const generateRetentionOfferName = (input: {amount: number; duration: str export { MAX_RETENTION_OFFER_NAME_LENGTH }; + +export type OfferType = 'percent' | 'fixed' | 'trial'; + +export const getOfferCadence = (cadence: string): string => { + return cadence === 'month' ? 'monthly' : 'yearly'; +}; + +export const getOfferDuration = (duration: string): string => { + return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever'); +}; + +export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => { + let discountOffer = ''; + const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0; + let updatedPrice = originalPrice; + + const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2)); + const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2}); + + let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice); + + switch (type) { + case 'percent': + discountOffer = `${formatNumber(amount)}% off`; + updatedPrice = originalPrice - ((originalPrice * amount) / 100); + break; + case 'fixed': + discountOffer = `${formatPrice(amount)} ${currency} off`; + updatedPrice = originalPrice - amount; + break; + case 'trial': + discountOffer = `${formatNumber(amount)} days free`; + originalPriceWithCurrency = ''; + break; + default: + break; + }; + + if (updatedPrice < 0) { + updatedPrice = 0; + } + + const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice); + + return { + discountOffer, + originalPriceWithCurrency, + updatedPriceWithCurrency + }; +}; diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx b/apps/admin/src/settings/growth/offers/offer-success.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx rename to apps/admin/src/settings/growth/offers/offer-success.tsx index a44d9708ff0..886675f9fe7 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offer-success.tsx +++ b/apps/admin/src/settings/growth/offers/offer-success.tsx @@ -95,7 +95,7 @@ const OfferSuccess: React.FC<{id: string}> = ({id}) => {
- +
OR
diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx similarity index 65% rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx rename to apps/admin/src/settings/growth/offers/offers-container-modal.tsx index a52a860888f..32d88808901 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offers-container-modal.tsx +++ b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx @@ -9,13 +9,24 @@ type OffersRouteHandlerProps = { route: string; }; +type RetentionCadence = 'monthly' | 'yearly'; + +const RETENTION_ROUTE_PREFIX = 'offers/edit/retention/'; + +const getRetentionCadence = (route: string): RetentionCadence | null => { + const suffix = route.slice(RETENTION_ROUTE_PREFIX.length).replace(/\/+$/, ''); + return suffix === 'monthly' || suffix === 'yearly' ? suffix : null; +}; + const OffersRouteHandler: React.FC = ({route}) => { if (route === 'offers/new') { return ; - } else if (route.startsWith('offers/edit/retention/') && route.length > 'offers/edit/retention/'.length) { - const retentionId = route.split('/').pop(); - return ; - } else if (route === 'offers/edit/retention/' || route === 'offers/edit/retention') { + } else if (route === 'offers/edit/retention' || route.startsWith(RETENTION_ROUTE_PREFIX)) { + const retentionCadence = route.startsWith(RETENTION_ROUTE_PREFIX) ? getRetentionCadence(route) : null; + if (retentionCadence) { + return ; + } + return ; } else if (route.startsWith('offers/edit/') && route.length > 'offers/edit/'.length) { const offerId = route.split('/').pop(); diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx b/apps/admin/src/settings/growth/offers/offers-index.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx rename to apps/admin/src/settings/growth/offers/offers-index.tsx index 55521fe5de3..c05440910b2 100644 --- a/apps/admin/src/settings/app/components/settings/growth/offers/offers-index.tsx +++ b/apps/admin/src/settings/growth/offers/offers-index.tsx @@ -5,62 +5,11 @@ import {type Offer, useBrowseOffers} from '@tryghost/admin-x-framework/api/offer import {type RetentionOffer, getRetentionOffers} from './offers-retention'; import {SettingsModal} from '@tryghost/shade/patterns'; import {type Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl} from './offer-helpers'; -import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl, getOfferCadence, getOfferDiscount, getOfferDuration} from './offer-helpers'; import {toast} from 'sonner'; import {useOffersShowArchived, useSortingState} from '@/settings/app/components/providers/settings-app-provider'; import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -export type OfferType = 'percent' | 'fixed' | 'trial'; - -export const getOfferCadence = (cadence: string): string => { - return cadence === 'month' ? 'monthly' : 'yearly'; -}; - -export const getOfferDuration = (duration: string): string => { - return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever'); -}; - -export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => { - let discountOffer = ''; - const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0; - let updatedPrice = originalPrice; - - const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2)); - const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2}); - - let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice); - - switch (type) { - case 'percent': - discountOffer = `${formatNumber(amount)}% off`; - updatedPrice = originalPrice - ((originalPrice * amount) / 100); - break; - case 'fixed': - discountOffer = `${formatPrice(amount)} ${currency} off`; - updatedPrice = originalPrice - amount; - break; - case 'trial': - discountOffer = `${formatNumber(amount)} days free`; - originalPriceWithCurrency = ''; - break; - default: - break; - }; - - if (updatedPrice < 0) { - updatedPrice = 0; - } - - const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice); - - return { - discountOffer, - originalPriceWithCurrency, - updatedPriceWithCurrency - }; -}; - const OffersFilterMenu: React.FC<{ showArchived: boolean; setShowArchived: (show: boolean) => void; diff --git a/apps/admin/src/settings/app/components/settings/growth/offers/offers-retention.tsx b/apps/admin/src/settings/growth/offers/offers-retention.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/growth/offers/offers-retention.tsx rename to apps/admin/src/settings/growth/offers/offers-retention.tsx diff --git a/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx b/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx index f99937593bb..b3288141eb4 100644 --- a/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx +++ b/apps/admin/src/settings/growth/recommendations.acceptance.test.tsx @@ -1,4 +1,4 @@ -import {describe, expect, it} from "vitest"; +import {describe, expect, it, vi} from "vitest"; import {browseResponse, currentRoute, fakeAdminEndpoint, fakeSettingsScreens, renderAdminApp} from "@test-utils/acceptance"; import {settingsScreen} from "@/settings/settings.screen"; @@ -33,7 +33,7 @@ const incomingRecommendations = [ excerpt: "Incoming recommendation 1 excerpt", featured_image: "https://incoming1.com/image.jpg", favicon: "https://incoming1.com/favicon.ico", - url: "https://incoming1.com/?ref=ghost", + url: "https://incoming1.com/path?ref=ghost&source=email#featured", recommending_back: false, }, { @@ -182,7 +182,40 @@ describe("Recommendations settings", () => { await expect.element(rows.last()).toHaveTextContent("Incoming recommendation 2 title"); await expect.element(rows.last()).toHaveTextContent("Recommending"); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + await rows.first().getByText("Incoming recommendation 1 title").click(); + expect(openSpy).toHaveBeenCalledWith(incomingRecommendations[0].url, "_blank", "noopener,noreferrer"); + openSpy.mockRestore(); + await rows.first().getByRole("button", {name: "Recommend back"}).click(); - await expect.poll(currentRoute).toBe("/settings/recommendations/add?url=https://incoming1.com/?ref=ghost"); + await expect.poll(currentRoute).toBe(`/settings/recommendations/add?url=${encodeURIComponent(incomingRecommendations[0].url)}`); + }); + + it.each([ + {scheme: "javascript", url: "javascript:alert(document.domain)"}, + {scheme: "data", url: "data:text/html,"}, + ])("blocks $scheme incoming recommendation URLs", async ({url}) => { + const unsafeRecommendation = { + ...incomingRecommendations[0], + id: `unsafe-${url}`, + title: "Unsafe incoming recommendation", + url, + }; + fakeSettingsScreens(); + fakeRecommendations(); + fakeAdminEndpoint("GET", /^\/incoming_recommendations\/\?/, browseResponse("recommendations", [unsafeRecommendation], {limit: 5})); + await renderAdminApp("/settings/recommendations"); + + const section = settingsScreen.section("recommendations"); + await section.getByRole("tab", {name: "Recommending you"}).click(); + const row = section.getByTestId("incoming-recommendation-list-item"); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + + await row.getByText("Unsafe incoming recommendation").click(); + expect(openSpy).not.toHaveBeenCalled(); + + await row.getByRole("button", {name: "Recommend back"}).click(); + await expect.poll(currentRoute).toBe("/settings/recommendations"); + openSpy.mockRestore(); }); }); diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations.tsx b/apps/admin/src/settings/growth/recommendations.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/growth/recommendations.tsx rename to apps/admin/src/settings/growth/recommendations.tsx diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx rename to apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx index 827068a6651..edb92496993 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal-confirm.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form'; +import RecommendationDescriptionForm from './recommendation-description-form'; +import {validateDescriptionForm} from './recommendation-validation'; import trackEvent from '@/settings/app/utils/analytics'; import {Button} from '@tryghost/shade/components'; import {type EditOrAddRecommendation, useAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx rename to apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx index 3917f0032c6..b0af9b4644c 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/add-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx @@ -139,13 +139,13 @@ const AddRecommendationModal: React.FC = () => { React.useEffect(() => { if (showLoadingView && !didInitialSubmit.current) { didInitialSubmit.current = true; - onOk(); + void onOk(); } }, [showLoadingView, onOk]); useEffect(() => { if (enterPressed) { - onOk(); + void onOk(); setEnterPressed(false); // Reset for future use } }, [formState]); diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx rename to apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx index 44b50f2c728..61c4a3d9c3e 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/edit-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import RecommendationDescriptionForm, {validateDescriptionForm} from './recommendation-description-form'; +import RecommendationDescriptionForm from './recommendation-description-form'; +import {validateDescriptionForm} from './recommendation-validation'; import {Button} from '@tryghost/shade/components'; import {type Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {SettingsModal} from '@tryghost/shade/patterns'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx rename to apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx index fcd0653cb55..9d8cfe23df6 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/incoming-recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx @@ -15,6 +15,15 @@ interface IncomingRecommendationListProps { isLoading: boolean } +const getHttpRecommendationUrl = (url: string): string | null => { + try { + const protocol = new URL(url).protocol; + return protocol === 'http:' || protocol === 'https:' ? url : null; + } catch { + return null; + } +}; + const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingRecommendation, stats: ReferrerHistoryItem[]}> = ({incomingRecommendation, stats}) => { const {updateRoute} = useSettingsNavigation(); @@ -36,11 +45,17 @@ const IncomingRecommendationItem: React.FC<{incomingRecommendation: IncomingReco }, [stats, incomingRecommendation.url]); const recommendBack = () => { - updateRoute({route: `recommendations/add?url=${incomingRecommendation.url}`}); + const url = getHttpRecommendationUrl(incomingRecommendation.url); + if (url) { + updateRoute({route: `recommendations/add?url=${encodeURIComponent(url)}`}); + } }; const showDetails = () => { - window.open(incomingRecommendation.url, '_blank'); + const url = getHttpRecommendationUrl(incomingRecommendation.url); + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + } }; const freeMembersLabel = signups === 1 ? 'free member' : 'free members'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx similarity index 81% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx rename to apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx index d9b2ded5275..ad8db68c384 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-description-form.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-description-form.tsx @@ -5,6 +5,7 @@ import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Textarea} from '@tryghost/shade/components'; import {Text} from '@tryghost/shade/primitives'; import {formatNumber} from '@tryghost/shade/utils'; +import {validateDescriptionForm} from './recommendation-validation'; interface Props { showURL?: boolean, @@ -15,39 +16,6 @@ interface Props { setErrors: (errors: ErrorMessages) => void } -export const validateDescriptionFormField = function (errors: ErrorMessages, field: 'title'|'description', value: string|null) { - const cloned = {...errors}; - switch (field) { - case 'title': - if (!value) { - cloned.title = 'Title is required'; - } else { - delete cloned.title; - } - break; - case 'description': - if (value && value.length > 200) { - cloned.description = 'Description cannot be longer than 200 characters'; - } else { - delete cloned.description; - } - break; - default: { - // Will throw a compile error if we forget to add a case for a field - const f: never = field; - throw new Error(`Unknown field ${f}`); - } - } - return cloned; -}; - -export const validateDescriptionForm = function (formState: EditOrAddRecommendation) { - let newErrors: ErrorMessages = {}; - newErrors = validateDescriptionFormField(newErrors, 'title', formState.title); - newErrors = validateDescriptionFormField(newErrors, 'description', formState.description); - return newErrors; -}; - function RecommendationDescriptionForm({showURL, formState, updateForm, errors, clearError, setErrors}: Props) { const [descriptionLength, setDescriptionLength] = React.useState(formState?.description?.length || 0); const descriptionLengthColor = descriptionLength > 200 ? 'text-destructive' : 'text-foreground'; diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-icon.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-icon.tsx rename to apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx diff --git a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx rename to apps/admin/src/settings/growth/recommendations/recommendation-list.tsx index 07e42d790b2..a595b982442 100644 --- a/apps/admin/src/settings/app/components/settings/growth/recommendations/recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx @@ -68,7 +68,7 @@ const RecommendationList: React.FC = ({recommendations, const [editingRecommendation, setEditingRecommendation] = useState(null); const copyRecommendationsUrl = () => { - navigator.clipboard.writeText(recommendationsURL); + void navigator.clipboard.writeText(recommendationsURL); setCopied(true); setTimeout(() => setCopied(false), 2000); }; diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts b/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts new file mode 100644 index 00000000000..35815ae1115 --- /dev/null +++ b/apps/admin/src/settings/growth/recommendations/recommendation-validation.ts @@ -0,0 +1,35 @@ +import {type EditOrAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; +import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks'; + +export const validateDescriptionFormField = function (errors: ErrorMessages, field: 'title'|'description', value: string|null) { + const cloned = {...errors}; + switch (field) { + case 'title': + if (!value) { + cloned.title = 'Title is required'; + } else { + delete cloned.title; + } + break; + case 'description': + if (value && value.length > 200) { + cloned.description = 'Description cannot be longer than 200 characters'; + } else { + delete cloned.description; + } + break; + default: { + // Will throw a compile error if we forget to add a case for a field + const f: never = field; + throw new Error(`Unknown field ${String(f)}`); + } + } + return cloned; +}; + +export const validateDescriptionForm = function (formState: EditOrAddRecommendation) { + let newErrors: ErrorMessages = {}; + newErrors = validateDescriptionFormField(newErrors, 'title', formState.title); + newErrors = validateDescriptionFormField(newErrors, 'description', formState.description); + return newErrors; +}; diff --git a/apps/admin/src/settings/growth/search-keywords.ts b/apps/admin/src/settings/growth/search-keywords.ts new file mode 100644 index 00000000000..d626653e1ab --- /dev/null +++ b/apps/admin/src/settings/growth/search-keywords.ts @@ -0,0 +1,7 @@ +export const searchKeywords = { + network: ['growth', 'network', 'activitypub', 'blog', 'fediverse', 'sharing'], + explore: ['ghost explore', 'explore', 'growth', 'share', 'list', 'listing'], + recommendations: ['growth', 'recommendations', 'recommend', 'blogroll'], + embedSignupForm: ['growth', 'embeddable signup form', 'embeddable form', 'embeddable sign up form', 'embeddable sign up'], + offers: ['growth', 'offers', 'discounts', 'coupons', 'promotions'] +}; diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts new file mode 100644 index 00000000000..43629aeb308 --- /dev/null +++ b/apps/admin/src/settings/growth/tips-and-donations-settings.test.ts @@ -0,0 +1,64 @@ +import {describe, expect, it} from 'vitest'; +import {parseTipsAndDonationsSettings} from './tips-and-donations-settings'; + +describe('parseTipsAndDonationsSettings', () => { + it('uses safe defaults when an older backend omits donation settings', () => { + expect(parseTipsAndDonationsSettings({})).toEqual({ + donations_currency: 'USD', + donations_suggested_amount: 500 + }); + }); + + it.each([ + {amount: 725, description: 'a backend number'}, + {amount: '725', description: 'a dirty local string'} + ])('accepts the suggested amount as $description', ({amount}) => { + expect(parseTipsAndDonationsSettings({ + donations_currency: 'EUR', + donations_suggested_amount: amount + })).toEqual({ + donations_currency: 'EUR', + donations_suggested_amount: 725 + }); + }); + + it.each([ + { + settings: {donations_currency: 'ZZZ', donations_suggested_amount: 500}, + description: 'an unsupported currency' + }, + { + settings: {donations_currency: null, donations_suggested_amount: 500}, + description: 'a null currency' + }, + { + settings: {donations_currency: 'GBP', donations_suggested_amount: 'not-a-number'}, + description: 'a malformed amount' + }, + { + settings: {donations_currency: 'GBP', donations_suggested_amount: null}, + description: 'a null amount' + } + ])('rejects $description instead of applying a missing-value default', ({settings}) => { + expect(() => parseTipsAndDonationsSettings(settings)).toThrow(); + }); + + it.each([null, [], 'invalid'])('rejects a non-object settings payload', (settings) => { + expect(() => parseTipsAndDonationsSettings(settings)).toThrow(); + }); + + it.each([ + {amount: -1, description: 'a negative amount'}, + {amount: 1.5, description: 'a fractional amount'}, + {amount: NaN, description: 'a NaN amount'}, + {amount: Infinity, description: 'a positive infinite amount'}, + {amount: -Infinity, description: 'a negative infinite amount'}, + {amount: Number.MAX_SAFE_INTEGER + 1, description: 'an unsafe integer amount'}, + {amount: '9007199254740992', description: 'an overflowing digit-only amount'} + ])('rejects $description', ({amount}) => { + expect(() => parseTipsAndDonationsSettings({ + donations_currency: 'USD', + donations_suggested_amount: amount + })).toThrow(); + }); +}); diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.ts new file mode 100644 index 00000000000..bf3a98b8378 --- /dev/null +++ b/apps/admin/src/settings/growth/tips-and-donations-settings.ts @@ -0,0 +1,35 @@ +import {currencySelectGroups} from '@/settings/app/utils/currency'; +import {z} from 'zod'; + +const DEFAULT_DONATIONS_CURRENCY = 'USD'; +const DEFAULT_DONATIONS_SUGGESTED_AMOUNT = 500; + +const supportedCurrencies = new Set( + currencySelectGroups().flatMap(group => group.options.map(option => option.value)) +); + +const donationsCurrencySchema = z.string() + .refine(currency => supportedCurrencies.has(currency)); + +const donationsSuggestedAmountNumberSchema = z.number() + .finite() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); + +const donationsSuggestedAmountSchema = z.union([ + donationsSuggestedAmountNumberSchema, + z.string() + .regex(/^\d+$/) + .transform(Number) + .pipe(donationsSuggestedAmountNumberSchema) +]); + +export const tipsAndDonationsSettingsSchema = z.object({ + donations_currency: donationsCurrencySchema.default(DEFAULT_DONATIONS_CURRENCY), + donations_suggested_amount: donationsSuggestedAmountSchema.default(DEFAULT_DONATIONS_SUGGESTED_AMOUNT) +}); + +export type TipsAndDonationsSettings = z.infer; + +export const parseTipsAndDonationsSettings = (settings: unknown): TipsAndDonationsSettings => tipsAndDonationsSettingsSchema.parse(settings); diff --git a/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx b/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx index 0a2c75796d6..98b0c724450 100644 --- a/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx +++ b/apps/admin/src/settings/growth/tips-and-donations.acceptance.test.tsx @@ -4,12 +4,13 @@ import { page, userEvent } from "vitest/browser"; import { currentRoute, fakeEditSettings, fakeSettingsScreens, renderAdminApp, settingsResponse, type RenderAdminAppOptions } from "@test-utils/acceptance"; import { settingsScreen } from "@/settings/settings.screen"; -function withStripe(): RenderAdminAppOptions { +function withStripe(settings: Record = {}): RenderAdminAppOptions { return { boot: { browseSettings: { response: settingsResponse({ settings: { + ...settings, donations_enabled: true, stripe_connect_publishable_key: "pk_test_123", stripe_connect_secret_key: "sk_test_123", @@ -54,7 +55,7 @@ describe("Tips and donations settings", () => { const section = settingsScreen.tipsAndDonations(); await expect.element(section).toBeVisible(); await expect.element(settingsScreen.suggestedAmount()).toHaveValue("5"); - await expect.element(section.getByRole("combobox")).toBeVisible(); + await expect.element(section.getByRole("combobox", {name: "Currency"})).toHaveTextContent("USD"); await expect.element(settingsScreen.donateUrl()).toHaveTextContent("http://test.com/#/portal/support"); await userEvent.hover(settingsScreen.donateUrl().element()); @@ -62,6 +63,28 @@ describe("Tips and donations settings", () => { await expect.element(settingsScreen.copyShareableLink()).toBeVisible(); }); + it.each([ + {amount: 725, expected: "7.25", source: "a backend number"}, + {amount: "825", expected: "8.25", source: "a dirty local string"}, + ])("accepts a valid suggested amount from $source", async ({amount, expected}) => { + fakeSettingsScreens(); + await renderAdminApp("/settings", withStripe({donations_suggested_amount: amount})); + + await expect.element(settingsScreen.suggestedAmount()).toHaveValue(expected); + }); + + it("shows the section error boundary for malformed donation settings", async () => { + fakeSettingsScreens(); + await renderAdminApp("/settings", withStripe({ + donations_currency: "ZZZ", + donations_suggested_amount: "not-a-number", + })); + + await expect.element(page.getByRole("alert")).toHaveTextContent("An error occurred loading Tips & donations. Please refresh and try again."); + await expect(settingsScreen.tipsAndDonations()).toHaveCount(0); + await expect(settingsScreen.suggestedAmount()).toHaveCount(0); + }); + it("saves an updated suggested amount", async () => { fakeSettingsScreens(); const settingsApi = fakeEditSettings(); diff --git a/apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx b/apps/admin/src/settings/growth/tips-and-donations.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx rename to apps/admin/src/settings/growth/tips-and-donations.tsx index 3bf14f7bf3b..c780ef7df6c 100644 --- a/apps/admin/src/settings/app/components/settings/growth/tips-and-donations.tsx +++ b/apps/admin/src/settings/growth/tips-and-donations.tsx @@ -8,6 +8,7 @@ import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns import {SettingGroupContent} from '@tryghost/shade/patterns'; import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {parseTipsAndDonationsSettings} from './tips-and-donations-settings'; import {withErrorBoundary} from '@/settings/app/components/error-boundary'; // Stripe doesn't allow amounts over 10,000 as a preset amount @@ -36,12 +37,18 @@ const TipsAndDonations: React.FC<{ keywords: string[] }> = ({keywords}) => { } }); - const [donationsCurrency = 'USD', donationsSuggestedAmount = '500'] = getSettingValues( + const [donationsCurrencySetting, donationsSuggestedAmountSetting] = getSettingValues( localSettings, ['donations_currency', 'donations_suggested_amount'] ); + const { + donations_currency: donationsCurrency, + donations_suggested_amount: suggestedAmountInCents + } = parseTipsAndDonationsSettings({ + donations_currency: donationsCurrencySetting, + donations_suggested_amount: donationsSuggestedAmountSetting + }); - const suggestedAmountInCents = parseInt(donationsSuggestedAmount); const suggestedAmountInput = useCurrencyInput(suggestedAmountInCents, cents => handleSettingChange('donations_suggested_amount', cents.toString())); const donateUrl = `${siteData?.url.replace(/\/$/, '')}/#/portal/support`; const currencyOptions = currencySelectGroups().flatMap(group => group.options.map(option => ({...option, metadata: {groupKey: group.key, groupLabel: group.label}}))); diff --git a/apps/admin/src/settings/app/components/settings/membership/access.tsx b/apps/admin/src/settings/membership/access.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/membership/access.tsx rename to apps/admin/src/settings/membership/access.tsx index 45f09427504..5d58e3c0372 100644 --- a/apps/admin/src/settings/app/components/settings/membership/access.tsx +++ b/apps/admin/src/settings/membership/access.tsx @@ -238,7 +238,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => { data-testid='regenerate-access-code' disabled={isRegenerating} size='icon-xs' - onClick={handleRegenerateAccessCode} + onClick={() => void handleRegenerateAccessCode()} > diff --git a/apps/admin/src/settings/app/components/settings/membership/analytics.tsx b/apps/admin/src/settings/membership/analytics.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/analytics.tsx rename to apps/admin/src/settings/membership/analytics.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx b/apps/admin/src/settings/membership/custom-fields.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx rename to apps/admin/src/settings/membership/custom-fields.tsx index 8174778a7c1..336ceb3381c 100644 --- a/apps/admin/src/settings/app/components/settings/membership/custom-fields.tsx +++ b/apps/admin/src/settings/membership/custom-fields.tsx @@ -212,7 +212,7 @@ const CustomFields: React.FC<{keywords: string[]}> = ({keywords}) => { // The server's message says what to do; refetching is how the screen // learns about the field it did not know about. handleError(error); - queryClient.invalidateQueries({queryKey: [memberCustomFieldsDataType]}); + void queryClient.invalidateQueries({queryKey: [memberCustomFieldsDataType]}); }) // Dropped either way: on success the cache holds this order, on failure the // server's is the one to show. diff --git a/apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx b/apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/custom-fields/custom-field-modal.tsx rename to apps/admin/src/settings/membership/custom-fields/custom-field-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/gift-subscriptions.tsx b/apps/admin/src/settings/membership/gift-subscriptions.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/gift-subscriptions.tsx rename to apps/admin/src/settings/membership/gift-subscriptions.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx b/apps/admin/src/settings/membership/member-emails.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/membership/member-emails.tsx rename to apps/admin/src/settings/membership/member-emails.tsx index 0723fb64035..6bd30bd2231 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails.tsx +++ b/apps/admin/src/settings/membership/member-emails.tsx @@ -224,7 +224,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { } }; - verify(); + void verify(); }, [confirm, handleError, verifyEmailToken, verifySenderUpdate]); const handleToggle = async (emailType: 'free' | 'paid') => { @@ -309,10 +309,10 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { paidEmailForDisplay={paidEmailForDisplay} paidWelcomeEmailEnabled={paidWelcomeEmailEnabled} settings={settings} - onFreeEdit={() => handleEditClick('free')} - onFreeToggle={() => handleToggle('free')} - onPaidEdit={() => handleEditClick('paid')} - onPaidToggle={() => handleToggle('paid')} + onFreeEdit={() => void handleEditClick('free')} + onFreeToggle={() => void handleToggle('free')} + onPaidEdit={() => void handleEditClick('paid')} + onPaidToggle={() => void handleToggle('paid')} /> {editingEmail && ( diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts b/apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts rename to apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/design-payload.test.ts b/apps/admin/src/settings/membership/member-emails/design-payload.test.ts similarity index 97% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/design-payload.test.ts rename to apps/admin/src/settings/membership/member-emails/design-payload.test.ts index 4ad853fe125..bf548ee5908 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/design-payload.test.ts +++ b/apps/admin/src/settings/membership/member-emails/design-payload.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {buildAutomatedEmailDesignPayload, mapApiToDesignSettings} from '@/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal'; +import {buildAutomatedEmailDesignPayload, mapApiToDesignSettings} from '@/settings/membership/member-emails/design-payload'; describe('Welcome email design payload helpers', function () { it('does not hydrate immutable api metadata into design settings', function () { diff --git a/apps/admin/src/settings/membership/member-emails/design-payload.ts b/apps/admin/src/settings/membership/member-emails/design-payload.ts new file mode 100644 index 00000000000..d3b57fca7c3 --- /dev/null +++ b/apps/admin/src/settings/membership/member-emails/design-payload.ts @@ -0,0 +1,75 @@ +import {type AutomatedEmailDesign, type EditAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design'; +import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/app/components/settings/email-design/types'; + +export interface GeneralSettings { + senderName: string; + senderEmail: string; + replyToEmail: string; + headerImage: string; + showPublicationIcon: boolean; + showPublicationTitle: boolean; + showBadge: boolean; + emailFooter: string; +} + +export interface WelcomeEmailCustomizeFormState { + designSettings: EmailDesignSettings; + generalSettings: GeneralSettings; +} + +const WELCOME_EMAIL_DESIGN_FIELDS = new Set(Object.keys(DEFAULT_EMAIL_DESIGN)); + +const isWelcomeEmailDesignField = (key: string) => WELCOME_EMAIL_DESIGN_FIELDS.has(key); + +/** + * Maps API response fields to the frontend GeneralSettings shape. + * Note: senderName, senderEmail and replyToEmail are not part of the design endpoint. + * + * @param {Pick} apiData - Subset of design fields used for general settings + * @param {GeneralSettings} defaults - Carries forward sender fields, which are not part of the design API + * @returns {GeneralSettings} General settings populated from the API response + */ +export function mapApiToGeneralSettings( + apiData: Pick, + defaults: GeneralSettings +): GeneralSettings { + return { + senderName: defaults.senderName, + senderEmail: defaults.senderEmail, + replyToEmail: defaults.replyToEmail, + headerImage: apiData.header_image || '', + showPublicationIcon: apiData.show_header_icon, + showPublicationTitle: apiData.show_header_title, + showBadge: apiData.show_badge, + emailFooter: apiData.footer_content || '' + }; +} + +/** + * Maps API response fields to the frontend welcome-email design settings shape. + * + * @param {EmailDesignSettings} apiData - The persisted design fields from the API response + * @returns {EmailDesignSettings} Design settings populated from the API response + */ +export function mapApiToDesignSettings( + apiData: EmailDesignSettings +): EmailDesignSettings { + return Object.fromEntries( + Object.entries(apiData).filter(([key]) => isWelcomeEmailDesignField(key)) + ) as EmailDesignSettings; +} + +export function buildAutomatedEmailDesignPayload(state: WelcomeEmailCustomizeFormState): EditAutomatedEmailDesign { + const persistedDesign = Object.fromEntries( + Object.entries(state.designSettings).filter(([key]) => isWelcomeEmailDesignField(key)) + ); + + return { + ...persistedDesign, + header_image: state.generalSettings.headerImage || null, + show_header_icon: state.generalSettings.showPublicationIcon, + show_header_title: state.generalSettings.showPublicationTitle, + show_badge: state.generalSettings.showBadge, + footer_content: state.generalSettings.emailFooter || null + }; +} diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/member-email-editor.tsx b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/member-email-editor.tsx rename to apps/admin/src/settings/membership/member-emails/member-email-editor.tsx index 9326efee0dd..4b45ffcc048 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/member-email-editor.tsx +++ b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx @@ -85,8 +85,8 @@ const EmailEditorInner: React.FC<{ registerAPI: (API: KoenigInstance | null) => void; onChange: (data: unknown) => void; }> = ({editor, darkMode, cardConfig, initialEditorState, placeholder, className, registerAPI, onChange}) => { - const koenig = editor.read(); - const EmailEditor = koenig.EmailEditor; + // loadKoenig resolves the untyped @tryghost/koenig-lexical bundle + const {EmailEditor} = editor.read() as {EmailEditor: React.ComponentType>}; return (
diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/test-email-dropdown.tsx b/apps/admin/src/settings/membership/member-emails/test-email-dropdown.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/test-email-dropdown.tsx rename to apps/admin/src/settings/membership/member-emails/test-email-dropdown.tsx index b4007fac5f3..cfacc488e9c 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/test-email-dropdown.tsx +++ b/apps/admin/src/settings/membership/member-emails/test-email-dropdown.tsx @@ -97,7 +97,7 @@ const TestEmailDropdown: React.FC = ({ className='w-full' disabled={sendState === 'sending'} type='button' - onClick={handleSendTestEmail} + onClick={() => void handleSendTestEmail()} > {sendState === 'sent' ? 'Sent' : sendState === 'sending' ? 'Sending...' : 'Send'} diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.test.tsx b/apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.test.tsx similarity index 91% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.test.tsx rename to apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.test.tsx index 10942b083c6..6075026a0b3 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.test.tsx +++ b/apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.test.tsx @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {act, renderHook, waitFor} from '@testing-library/react'; -import {useWelcomeEmailPreview} from '@/settings/app/components/settings/membership/member-emails/use-welcome-email-preview'; +import {useWelcomeEmailPreview} from '@/settings/membership/member-emails/use-welcome-email-preview'; const validLexical = JSON.stringify({ root: { @@ -41,7 +41,7 @@ const createDeferred = () => { }; describe('useWelcomeEmailPreview', function () { - it('returns invalid state and skips network for invalid drafts', async function () { + it('returns invalid state and skips network for invalid drafts', function () { const previewWelcomeEmail = vi.fn(); const setErrors = vi.fn(); const {result} = renderHook(() => useWelcomeEmailPreview({ @@ -51,7 +51,7 @@ describe('useWelcomeEmailPreview', function () { })); act(() => { - result.current.enterPreview({subject: ' ', lexical: ''}); + void result.current.enterPreview({subject: ' ', lexical: ''}); }); assert.equal(previewWelcomeEmail.mock.calls.length, 0); @@ -77,8 +77,8 @@ describe('useWelcomeEmailPreview', function () { })); act(() => { - result.current.enterPreview({subject: 'First', lexical: validLexical}); - result.current.enterPreview({subject: 'Second', lexical: validLexical}); + void result.current.enterPreview({subject: 'First', lexical: validLexical}); + void result.current.enterPreview({subject: 'Second', lexical: validLexical}); }); await act(async () => { @@ -138,7 +138,7 @@ describe('useWelcomeEmailPreview', function () { })); act(() => { - result.current.enterPreview({subject: 'Welcome', lexical: validLexical}); + void result.current.enterPreview({subject: 'Welcome', lexical: validLexical}); }); await waitFor(() => { @@ -162,7 +162,7 @@ describe('useWelcomeEmailPreview', function () { })); act(() => { - result.current.enterPreview({subject: 'Welcome', lexical: validLexical}); + void result.current.enterPreview({subject: 'Welcome', lexical: validLexical}); }); await waitFor(() => { diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.ts b/apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/use-welcome-email-preview.ts rename to apps/admin/src/settings/membership/member-emails/use-welcome-email-preview.ts diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx similarity index 87% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx rename to apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx index dac07627acb..8a8b9dbfe3b 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-customize-modal.tsx +++ b/apps/admin/src/settings/membership/member-emails/welcome-email-customize-modal.tsx @@ -5,7 +5,7 @@ import ShowBadgeField from '@/settings/app/components/settings/email-design/show import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import validator from 'validator'; -import {type AutomatedEmailDesign, type EditAutomatedEmailDesign, useEditAutomatedEmailDesign, useReadAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design'; +import {useEditAutomatedEmailDesign, useReadAutomatedEmailDesign} from '@tryghost/admin-x-framework/api/automated-email-design'; import { BackgroundColorField, BodyFontField, @@ -25,6 +25,7 @@ import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '@/settings/app/com import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; import {Input, LoadingIndicator, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components'; import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailValues} from './default-welcome-email-values'; +import {type GeneralSettings, type WelcomeEmailCustomizeFormState, buildAutomatedEmailDesignPayload, mapApiToDesignSettings, mapApiToGeneralSettings} from './design-payload'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmailSenders} from '@tryghost/admin-x-framework/api/automated-emails'; @@ -33,26 +34,8 @@ import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; import {useWelcomeEmailSenderDetails} from '@/settings/app/hooks/use-welcome-email-sender-details'; -interface GeneralSettings { - senderName: string; - senderEmail: string; - replyToEmail: string; - headerImage: string; - showPublicationIcon: boolean; - showPublicationTitle: boolean; - showBadge: boolean; - emailFooter: string; -} - -interface WelcomeEmailCustomizeFormState { - designSettings: EmailDesignSettings; - generalSettings: GeneralSettings; -} - const SAVE_ERROR_TOAST_ID = 'welcome-email-design-save-error'; -const WELCOME_EMAIL_DESIGN_FIELDS = new Set(Object.keys(DEFAULT_EMAIL_DESIGN)); -const isWelcomeEmailDesignField = (key: string) => WELCOME_EMAIL_DESIGN_FIELDS.has(key); interface GeneralTabProps { generalSettings: GeneralSettings; onGeneralChange: (updates: Partial) => void; @@ -268,58 +251,6 @@ const Sidebar: React.FC = ({ ); -/** - * Maps API response fields to the frontend GeneralSettings shape. - * Note: senderName, senderEmail and replyToEmail are not part of the design endpoint. - * - * @param {Pick} apiData - Subset of design fields used for general settings - * @param {GeneralSettings} defaults - Carries forward sender fields, which are not part of the design API - * @returns {GeneralSettings} General settings populated from the API response - */ -function mapApiToGeneralSettings( - apiData: Pick, - defaults: GeneralSettings -): GeneralSettings { - return { - senderName: defaults.senderName, - senderEmail: defaults.senderEmail, - replyToEmail: defaults.replyToEmail, - headerImage: apiData.header_image || '', - showPublicationIcon: apiData.show_header_icon, - showPublicationTitle: apiData.show_header_title, - showBadge: apiData.show_badge, - emailFooter: apiData.footer_content || '' - }; -} - -/** - * Maps API response fields to the frontend welcome-email design settings shape. - * - * @param {EmailDesignSettings} apiData - The persisted design fields from the API response - * @returns {EmailDesignSettings} Design settings populated from the API response - */ -export function mapApiToDesignSettings( - apiData: EmailDesignSettings -): EmailDesignSettings { - return Object.fromEntries( - Object.entries(apiData).filter(([key]) => isWelcomeEmailDesignField(key)) - ) as EmailDesignSettings; -} - -export function buildAutomatedEmailDesignPayload(state: WelcomeEmailCustomizeFormState): EditAutomatedEmailDesign { - const persistedDesign = Object.fromEntries( - Object.entries(state.designSettings).filter(([key]) => isWelcomeEmailDesignField(key)) - ); - - return { - ...persistedDesign, - header_image: state.generalSettings.headerImage || null, - show_header_icon: state.generalSettings.showPublicationIcon, - show_header_title: state.generalSettings.showPublicationTitle, - show_badge: state.generalSettings.showBadge, - footer_content: state.generalSettings.emailFooter || null - }; -} const ErrorState: React.FC<{message: string}> = ({message}) => (
@@ -565,7 +496,7 @@ const WelcomeEmailCustomizeModal: React.FC<{onClose: () => void}> = ({onClose}) title="Welcome emails" open onClose={onClose} - onSave={() => handleSave({fakeWhenUnchanged: true})} + onSave={() => void handleSave({fakeWhenUnchanged: true})} /> ); diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx rename to apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx index ac9c09155d5..aeb42dea28e 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-modal.tsx +++ b/apps/admin/src/settings/membership/member-emails/welcome-email-modal.tsx @@ -154,7 +154,7 @@ const WelcomeEmailModal: React.FC void} const handleCMDS = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); - handleSaveRef.current({fakeWhenUnchanged: true}); + void handleSaveRef.current({fakeWhenUnchanged: true}); } }; window.addEventListener('keydown', handleCMDS); @@ -168,7 +168,7 @@ const WelcomeEmailModal: React.FC void} if (nextMode === 'preview') { setPreviewSubjectOverride(null); - enterPreview(formState); + void enterPreview(formState); } else { setShowTestDropdown(false); setPreviewSubjectOverride(null); @@ -235,7 +235,7 @@ const WelcomeEmailModal: React.FC void} diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-preview-frame.tsx b/apps/admin/src/settings/membership/member-emails/welcome-email-preview-frame.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-preview-frame.tsx rename to apps/admin/src/settings/membership/member-emails/welcome-email-preview-frame.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-validation.ts b/apps/admin/src/settings/membership/member-emails/welcome-email-validation.ts similarity index 89% rename from apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-validation.ts rename to apps/admin/src/settings/membership/member-emails/welcome-email-validation.ts index 19ca3943c3b..be76cc67663 100644 --- a/apps/admin/src/settings/app/components/settings/membership/member-emails/welcome-email-validation.ts +++ b/apps/admin/src/settings/membership/member-emails/welcome-email-validation.ts @@ -9,7 +9,7 @@ const isEmptyLexical = (lexical: string | null | undefined): boolean => { } try { - const parsed = JSON.parse(lexical); + const parsed = JSON.parse(lexical) as {root?: {children?: Array<{type?: string; children?: unknown[]}>}} | null; const children = parsed?.root?.children; // Empty if no children or only an empty paragraph diff --git a/apps/admin/src/settings/app/components/settings/membership/membership-settings.tsx b/apps/admin/src/settings/membership/membership-settings.tsx similarity index 68% rename from apps/admin/src/settings/app/components/settings/membership/membership-settings.tsx rename to apps/admin/src/settings/membership/membership-settings.tsx index 22a1df67676..5ea66fe386a 100644 --- a/apps/admin/src/settings/app/components/settings/membership/membership-settings.tsx +++ b/apps/admin/src/settings/membership/membership-settings.tsx @@ -7,21 +7,12 @@ import React from 'react'; import SearchableSection from '@/settings/app/components/searchable-section'; import SpamFilters from '@/settings/app/components/settings/advanced/spam-filters'; import Tiers from './tiers'; -import TipsAndDonations from '@/settings/app/components/settings/growth/tips-and-donations'; +import TipsAndDonations from '@/settings/growth/tips-and-donations'; import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {searchKeywords} from './search-keywords'; import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -export const searchKeywords = { - access: ['membership', 'default', 'access', 'subscription', 'post', 'membership', 'comments', 'commenting', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email', 'password protection', 'lock site', 'private site', 'private site mode', 'make this site private'], - tiers: ['membership', 'tiers', 'payment', 'paid', 'stripe'], - portal: ['membership', 'portal', 'signup', 'sign up', 'signin', 'sign in', 'login', 'account', 'membership', 'support', 'email', 'address', 'support email address', 'support address'], - giftSubscriptions: ['membership', 'gift', 'gifts', 'gift subscriptions', 'present', 'share', 'shareable link'], - memberEmails: ['membership', 'signup', 'welcome email', 'welcome emails', 'email', 'new user', 'new member', 'account'], - tips: ['membership', 'tips', 'donations', 'one time', 'payment'], - customFields: ['membership', 'custom fields', 'fields', 'member fields', 'custom field'] -}; - const MembershipSettings: React.FC = () => { const {config, settings} = useGlobalData(); const [hasTipsAndDonations, paidMembersEnabled] = getSettingValues(settings, ['donations_enabled', 'paid_members_enabled']) as [boolean, boolean]; diff --git a/apps/admin/src/settings/app/components/settings/membership/portal.tsx b/apps/admin/src/settings/membership/portal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/portal.tsx rename to apps/admin/src/settings/membership/portal.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/account-page.tsx b/apps/admin/src/settings/membership/portal/account-page.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/portal/account-page.tsx rename to apps/admin/src/settings/membership/portal/account-page.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/look-and-feel.tsx b/apps/admin/src/settings/membership/portal/look-and-feel.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/membership/portal/look-and-feel.tsx rename to apps/admin/src/settings/membership/portal/look-and-feel.tsx index 4382ceefb7b..5baed289ce8 100644 --- a/apps/admin/src/settings/app/components/settings/membership/portal/look-and-feel.tsx +++ b/apps/admin/src/settings/membership/portal/look-and-feel.tsx @@ -132,7 +132,7 @@ const LookAndFeel: React.FC<{ ) : ( - handleImageUpload(files[0])}> + void handleImageUpload(files[0])}> )} diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-frame.tsx b/apps/admin/src/settings/membership/portal/portal-frame.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-frame.tsx rename to apps/admin/src/settings/membership/portal/portal-frame.tsx index 7606a17fb56..35527361589 100644 --- a/apps/admin/src/settings/app/components/settings/membership/portal/portal-frame.tsx +++ b/apps/admin/src/settings/membership/portal/portal-frame.tsx @@ -33,7 +33,7 @@ const PortalFrame: React.FC = ({href, onDestroyed, selectedTab const originURL = new URL(event.origin); if (originURL.origin === new URL(href).origin) { - if (event?.data?.type === 'portal-preview-ready') { + if ((event.data as {type?: string} | null)?.type === 'portal-preview-ready') { makeVisible(); } } diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-links.tsx b/apps/admin/src/settings/membership/portal/portal-links.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-links.tsx rename to apps/admin/src/settings/membership/portal/portal-links.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx b/apps/admin/src/settings/membership/portal/portal-modal.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx rename to apps/admin/src/settings/membership/portal/portal-modal.tsx index 0c2f988aa0a..d2e77b9f395 100644 --- a/apps/admin/src/settings/app/components/settings/membership/portal/portal-modal.tsx +++ b/apps/admin/src/settings/membership/portal/portal-modal.tsx @@ -95,11 +95,10 @@ const PortalModal: React.FC = () => { cancelLabel: '', onOk: confirmModal => confirmModal?.remove() }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { + } catch (e) { let prompt = 'There was an error verifying your email address. Please try again.'; - if (e?.message === 'Token expired') { + if (e instanceof Error && e.message === 'Token expired') { prompt = 'Verification link has expired.'; } confirm({ @@ -113,14 +112,14 @@ const PortalModal: React.FC = () => { } }; if (verifyEmail) { - checkToken({token: verifyEmail}); + void checkToken({token: verifyEmail}); } }, [confirm, handleError, verifyEmail, verifyToken]); - const {formState, setFormState, saveState, handleSave, updateForm, okProps} = useForm({ + const {formState, setFormState, saveState, handleSave, updateForm, okProps} = useForm<{settings: Dirtyable[]; tiers: Dirtyable[]}>({ initialState: { - settings: settings as Dirtyable[], - tiers: allTiers as Dirtyable[] || [] + settings, + tiers: allTiers || [] }, savingDelay: 500, diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/portal-preview.tsx b/apps/admin/src/settings/membership/portal/portal-preview.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/portal/portal-preview.tsx rename to apps/admin/src/settings/membership/portal/portal-preview.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/signup-options.tsx b/apps/admin/src/settings/membership/portal/signup-options.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/portal/signup-options.tsx rename to apps/admin/src/settings/membership/portal/signup-options.tsx diff --git a/apps/admin/src/settings/app/components/settings/membership/portal/transistor-settings.tsx b/apps/admin/src/settings/membership/portal/transistor-settings.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/membership/portal/transistor-settings.tsx rename to apps/admin/src/settings/membership/portal/transistor-settings.tsx diff --git a/apps/admin/src/settings/membership/search-keywords.ts b/apps/admin/src/settings/membership/search-keywords.ts new file mode 100644 index 00000000000..3bcb92ebb35 --- /dev/null +++ b/apps/admin/src/settings/membership/search-keywords.ts @@ -0,0 +1,9 @@ +export const searchKeywords = { + access: ['membership', 'default', 'access', 'subscription', 'post', 'membership', 'comments', 'commenting', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email', 'password protection', 'lock site', 'private site', 'private site mode', 'make this site private'], + tiers: ['membership', 'tiers', 'payment', 'paid', 'stripe'], + portal: ['membership', 'portal', 'signup', 'sign up', 'signin', 'sign in', 'login', 'account', 'membership', 'support', 'email', 'address', 'support email address', 'support address'], + giftSubscriptions: ['membership', 'gift', 'gifts', 'gift subscriptions', 'present', 'share', 'shareable link'], + memberEmails: ['membership', 'signup', 'welcome email', 'welcome emails', 'email', 'new user', 'new member', 'account'], + tips: ['membership', 'tips', 'donations', 'one time', 'payment'], + customFields: ['membership', 'custom fields', 'fields', 'member fields', 'custom field'] +}; diff --git a/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx b/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx rename to apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx index f8dd8740182..7a581400968 100644 --- a/apps/admin/src/settings/app/components/settings/membership/stripe/stripe-connect-modal.tsx +++ b/apps/admin/src/settings/membership/stripe/stripe-connect-modal.tsx @@ -144,7 +144,7 @@ const Connect: React.FC = () => {