Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ const features: Feature[] = [{
description: 'Preview the next version of the admin interface',
flag: 'adminForward'
}, {
title: 'Domain Warmup',
description: 'Enable custom sending domain warmup for gradual email volume increases',
flag: 'domainWarmup'
},{
title: 'Updated theme translation (beta)',
description: 'Enable theme translation using i18next instead of the old translation package.',
flag: 'themeTranslation'
Expand Down
149 changes: 86 additions & 63 deletions apps/admin/src/layout/app-sidebar/hooks/use-featurebase.ts
Original file line number Diff line number Diff line change
@@ -1,116 +1,139 @@
import {useCallback, useEffect, useRef} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {getFeaturebaseToken} from '@tryghost/admin-x-framework';
import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user';
import {useFeatureFlag} from '@/hooks/use-feature-flag';
import {useUserPreferences} from '@/hooks/user-preferences';
import {deferred, type Deferred} from '@/utils/deferred';

type FeaturebaseCallback = (err: unknown, data?: unknown) => void;
type FeaturebaseFunction = (action: string, options: Record<string, unknown>, callback?: FeaturebaseCallback) => void;

declare global {
interface Window {
Featurebase?: FeaturebaseFunction & {q?: unknown[]};
Featurebase?: FeaturebaseFunction;
}
}

const SDK_URL = 'https://do.featurebase.app/js/sdk.js';
const DEFAULT_BOARD = 'Feature Request';

let featurebaseSDKPromise: Promise<void> | null = null;
function loadFeaturebaseSDK(): Promise<void> {
return new Promise((resolve, reject) => {
const existingScript = document.querySelector(`script[src="${SDK_URL}"]`);
if (existingScript) {
resolve();
return;
}
if (!featurebaseSDKPromise) {
featurebaseSDKPromise = new Promise((resolve, reject) => {
const existingScript = document.querySelector(`script[src="${SDK_URL}"]`);
if (existingScript) {
resolve();
return;
}

const script = document.createElement('script');
script.src = SDK_URL;
script.onload = () => resolve();
script.onerror = (event) => {
script.remove();
const error = new Error(`[Featurebase] Failed to load SDK from ${SDK_URL}`, {cause: event});
console.error(error);
reject(error);
};
document.head.appendChild(script);

// Set up the queue function while script loads
if (typeof window.Featurebase !== 'function') {
window.Featurebase = function (...args: unknown[]) {
(window.Featurebase!.q = window.Featurebase!.q || []).push(args);
} as FeaturebaseFunction & {q?: unknown[]};
}
});
const script = document.createElement('script');
script.src = SDK_URL;
script.onload = () => resolve();
script.onerror = (event) => {
script.remove();
featurebaseSDKPromise = null; // Allow retry on next interaction
const error = new Error(`[Featurebase] Failed to load SDK from ${SDK_URL}`, {cause: event});
console.error(error);
reject(error);
};
document.head.appendChild(script);
});
}
return featurebaseSDKPromise;
}

interface Featurebase {
openFeedbackWidget: (options?: {board?: string}) => void;
preloadFeedbackWidget: () => void;
}

/**
* Hook for lazy-loading and interacting with the Featurebase feedback widget.
*
* The SDK and authentication token are NOT fetched on mount. Instead, loading
* is deferred until user interaction (hover/focus/click on the Feedback button).
* This improves initial page load performance.
*/
export function useFeaturebase(): Featurebase {
const {data: currentUser} = useCurrentUser();
const {data: config} = useBrowseConfig();
const {data: preferences} = useUserPreferences();
const featureFlagEnabled = useFeatureFlag('featurebaseFeedback');
const isInitializingRef = useRef(false);
const initializedWithRef = useRef<{theme: string; token: string} | null>(null);
const [shouldLoad, setShouldLoad] = useState(false);

const featurebaseConfig = config?.config.featurebase;
const featurebaseOrg = featurebaseConfig?.organization;
const featurebaseEnabled = !!(featureFlagEnabled && featurebaseConfig?.enabled);
const {organization, enabled} = config?.config.featurebase ?? {};
const featurebaseEnabled = !!(featureFlagEnabled && enabled);
const theme = preferences?.nightShift ? 'dark' : 'light';

// Token is only fetched once shouldLoad becomes true (on user interaction)
const {data: tokenData} = getFeaturebaseToken({
enabled: featurebaseEnabled
enabled: featurebaseEnabled && shouldLoad
});
const token = tokenData?.featurebase?.token;

useEffect(() => {
if (!featurebaseEnabled || !featurebaseOrg || !currentUser || !token) {
return;
if (shouldLoad) {
loadFeaturebaseSDK().catch((err) => {
console.error('[Featurebase] Failed to load SDK:', err);
});
}
}, [shouldLoad]);

const initializedWith = initializedWithRef.current;
const initializedWithSameData = initializedWith && initializedWith.theme === theme && initializedWith.token === token;

if (isInitializingRef.current || initializedWithSameData) {
const deferredInitRef = useRef<Deferred<void>>(deferred());
useEffect(() => {
if (!shouldLoad || !organization || !token) {
return;
}

isInitializingRef.current = true;

loadFeaturebaseSDK().then(() => {
void featurebaseSDKPromise?.then(() => {
window.Featurebase?.('initialize_feedback_widget', {
organization: featurebaseOrg,
organization,
theme,
defaultBoard: 'Feature Request',
defaultBoard: DEFAULT_BOARD,
featurebaseJwt: token
}, (err) => {
isInitializingRef.current = false;

if (err) {
console.error('[Featurebase] Failed to initialize widget:', err);
initializedWithRef.current = null;
deferredInitRef.current.reject(err);

// reset so we can retry on next interaction
deferredInitRef.current = deferred();
setShouldLoad(false);
} else {
initializedWithRef.current = {theme, token};
deferredInitRef.current.resolve();
}
});
}).catch(() => {
isInitializingRef.current = false;
initializedWithRef.current = null;
});
}, [featurebaseEnabled, featurebaseOrg, currentUser, token, theme]);
}, [organization, theme, token, shouldLoad]);

/**
* Called on hover/focus to start loading SDK + fetching token in advance.
* This makes the widget open faster when the user actually clicks.
*/
const preloadFeedbackWidget = useCallback(() => {
if (!featurebaseEnabled) {
return;
}
// Trigger SDK loading and initialization via effects above
setShouldLoad(true);
}, [featurebaseEnabled]);

const openFeedbackWidget = useCallback((options?: {board?: string}) => {
window.postMessage({
target: 'FeaturebaseWidget',
data: {
action: 'openFeedbackWidget',
...(options?.board && {setBoard: options.board})
}
}, '*');
}, []);
if (!featurebaseEnabled) {
return;
}

setShouldLoad(true);

void deferredInitRef.current.promise.then(() => {
window.postMessage({
target: 'FeaturebaseWidget',
data: {
action: 'openFeedbackWidget',
...(options?.board && {setBoard: options.board})
}
}, '*');
});
}, [featurebaseEnabled]);

return {openFeedbackWidget};
return {openFeedbackWidget, preloadFeedbackWidget};
}
4 changes: 2 additions & 2 deletions apps/admin/src/layout/app-sidebar/nav-ghost-pro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function NavGhostPro({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
const { data: currentUser } = useCurrentUser();
const { data: config } = useBrowseConfig();
const featurebaseFeedbackFlag = useFeatureFlag('featurebaseFeedback');
const { openFeedbackWidget } = useFeaturebase();
const { openFeedbackWidget, preloadFeedbackWidget } = useFeaturebase();

if (!currentUser) {
return null;
Expand Down Expand Up @@ -44,7 +44,7 @@ function NavGhostPro({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
)}
{showFeedback && (
<NavMenuItem>
<NavMenuItem.Button onClick={openFeedbackWidget}>
<NavMenuItem.Button onClick={openFeedbackWidget} onMouseEnter={preloadFeedbackWidget} onFocus={preloadFeedbackWidget}>
<LucideIcon.MessageCircle />
<NavMenuItem.Label>Feedback</NavMenuItem.Label>
</NavMenuItem.Button>
Expand Down
28 changes: 28 additions & 0 deletions apps/admin/src/utils/deferred.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {describe, expect, it} from 'vitest';
import {deferred} from './deferred';

describe('deferred', () => {
it('returns a promise with externally accessible resolve and reject functions', () => {
const d = deferred<string>();

expect(d.promise).toBeInstanceOf(Promise);
expect(typeof d.resolve).toBe('function');
expect(typeof d.reject).toBe('function');
});

it('resolve function resolves the promise', async () => {
const d = deferred<string>();

d.resolve('test value');

await expect(d.promise).resolves.toBe('test value');
});

it('reject function rejects the promise', async () => {
const d = deferred<string>();

d.reject(new Error('test error'));

await expect(d.promise).rejects.toThrow('test error');
});
});
10 changes: 10 additions & 0 deletions apps/admin/src/utils/deferred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const deferred = function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {promise, resolve, reject};
};
export type Deferred<T> = ReturnType<typeof deferred<T>>;
6 changes: 4 additions & 2 deletions ghost/admin/app/components/editor/email-size-warning.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export default class EmailSizeWarningComponent extends Component {
get isEnabled() {
return this.settings.editorDefaultEmailRecipients !== 'disabled'
&& this.args.post
&& !this.args.post.email
&& !this.args.post.isNew;
&& !this.args.post.isNew
&& !this.args.post.hasEmail
&& !this.args.post.isPublished
&& !this.args.post.isPage;
}

constructor() {
Expand Down
19 changes: 0 additions & 19 deletions ghost/admin/app/services/email-size-warning.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export default class EmailSizeWarningService extends Service {

@inject config;

_newsletter = null;
_lastPostId = null;
_lastUpdatedAt = null;

Expand Down Expand Up @@ -51,18 +50,6 @@ export default class EmailSizeWarningService extends Service {
return this._fetchTask.perform(post);
}

async _loadNewsletter() {
if (!this._newsletter) {
const newsletters = await this.store.query('newsletter', {
filter: 'status:active',
order: 'sort_order DESC',
limit: 1
});
this._newsletter = newsletters.firstObject;
}
return this._newsletter;
}

_calculateLinkRewritingAdjustment(html) {
const contentStartMarker = '<!-- POST CONTENT START -->';
const contentEndMarker = '<!-- POST CONTENT END -->';
Expand Down Expand Up @@ -105,17 +92,11 @@ export default class EmailSizeWarningService extends Service {

@task
*_fetchTask(post) {
yield this._loadNewsletter();
if (!this._newsletter) {
return {overLimit: false, emailSizeKb: null};
}

try {
const url = new URL(
this.ghostPaths.url.api('/email_previews/posts', post.id),
window.location.href
);
url.searchParams.set('newsletter', this._newsletter.slug);

const response = yield this.ajax.request(url.href);
const [emailPreview] = response.email_previews;
Expand Down
4 changes: 4 additions & 0 deletions ghost/admin/app/styles/layouts/member-activity.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@
padding-bottom: 12px;
}

.gh-members-activity .gh-list-scrolling tbody tr:hover > .gh-list-data {
background: var(--whitegrey-l2);
}

.gh-members-activity .gh-list h3 {
margin-bottom: 4px;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ export type EmailAddressesValidation = {

export type EmailAddressType = 'from' | 'replyTo';

type LabsService = {
isSet: (flag: string) => boolean
}

type GetAddressOptions = {
useFallbackAddress: boolean
}
Expand All @@ -30,17 +26,14 @@ export class EmailAddressService {
#getFallbackDomain: () => string | null;
#getFallbackEmail: () => EmailAddress | null;
#isValidEmailAddress: (email: string) => boolean;
#labs: LabsService;

constructor(dependencies: {
getManagedEmailEnabled: () => boolean,
getSendingDomain: () => string | null,
getFallbackDomain: () => string | null,
getDefaultEmail: () => EmailAddress,
getFallbackEmail: () => string | null,
isValidEmailAddress: (email: string) => boolean,
labs: LabsService

isValidEmailAddress: (email: string) => boolean
}) {
this.#getManagedEmailEnabled = dependencies.getManagedEmailEnabled;
this.#getSendingDomain = dependencies.getSendingDomain;
Expand All @@ -54,7 +47,6 @@ export class EmailAddressService {
return EmailAddressParser.parse(fallbackAddress);
};
this.#isValidEmailAddress = dependencies.isValidEmailAddress;
this.#labs = dependencies.labs;
}

get sendingDomain(): string | null {
Expand Down Expand Up @@ -117,7 +109,7 @@ export class EmailAddressService {
}

// Case: use fallback address when warming up custom domain
if (this.#labs.isSet('domainWarmup') && options.useFallbackAddress) {
if (options.useFallbackAddress) {
const fallbackEmail = this.fallbackEmail;
if (fallbackEmail) {
if (!fallbackEmail.name) {
Expand Down
Loading