-
-
Notifications
You must be signed in to change notification settings - Fork 11.2k
Added warning icon to editor when email size exceeds clipping threshold #25689
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
1ed5c1f
Added warning icon to editor when email size exceeds clipping threshold
kevinansfield cd482d8
Added email size length adjustment for URL rewrite diff between previ…
kevinansfield d5ab319
Refined icon + popup
peterzimon 78ec7de
Refined animations
peterzimon 8867ad5
extracted logic to service to avoid multiple API requests
kevinansfield File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| {{#if this.isEnabled}} | ||
| {{! Watch for post.updatedAt changes to trigger recalculation after saves }} | ||
| <span {{did-update this.checkEmailSize @post.updatedAtUTC}} class="gh-editor-email-size-warning-container"> | ||
| <span class="gh-editor-email-size-warning gh-editor-email-size-warning--{{this.warningLevel}}" data-warning-active={{if this.warningLevel "true" "false"}}> | ||
| {{#if this.warningLevel}} | ||
| <span class="{{concat "gh-editor-email-warning-icon-" this.warningLevel}}"> | ||
| {{svg-jar "email-warning"}} | ||
| </span> | ||
| {{/if}} | ||
| </span> | ||
| {{#if this.warningLevel}} | ||
| <div class="gh-editor-email-size-popup"> | ||
| <div class="gh-editor-email-size-popup-title">Looks like this is a long post</div> | ||
| <div class="gh-editor-email-size-popup-text">Email newsletters may get cut off in the inbox behind a "View entire message" link when they're over 100kB.</div> | ||
| <div class="gh-editor-email-size-popup-used">You've used: <span class={{this.warningLevel}}>{{this.emailSizeKb}}kB</span></div> | ||
| </div> | ||
| {{/if}} | ||
| </span> | ||
| {{/if}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import Component from '@glimmer/component'; | ||
| import {action} from '@ember/object'; | ||
| import {inject as service} from '@ember/service'; | ||
| import {task} from 'ember-concurrency'; | ||
| import {tracked} from '@glimmer/tracking'; | ||
|
|
||
| export default class EmailSizeWarningComponent extends Component { | ||
| @service emailSizeWarning; | ||
| @service feature; | ||
| @service settings; | ||
|
|
||
| @tracked warningLevel = null; | ||
| @tracked emailSizeKb = null; | ||
|
|
||
| get isEnabled() { | ||
| return this.feature.emailSizeWarnings | ||
| && this.settings.editorDefaultEmailRecipients !== 'disabled' | ||
| && this.post | ||
| && !this.post.email | ||
| && !this.post.isNew; | ||
| } | ||
|
|
||
| get post() { | ||
| return this.args.post; | ||
| } | ||
|
|
||
| constructor() { | ||
| super(...arguments); | ||
| if (this.isEnabled) { | ||
| this.checkEmailSizeTask.perform(); | ||
| } | ||
| } | ||
|
|
||
| @action | ||
| checkEmailSize() { | ||
| if (this.isEnabled) { | ||
| this.checkEmailSizeTask.perform(); | ||
| } | ||
| } | ||
|
|
||
| @task({restartable: true}) | ||
| *checkEmailSizeTask() { | ||
| const result = yield this.emailSizeWarning.fetchEmailSize(this.post); | ||
| this.warningLevel = result.warningLevel; | ||
| this.emailSizeKb = result.emailSizeKb; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import Service, {inject as service} from '@ember/service'; | ||
| import {inject} from 'ghost-admin/decorators/inject'; | ||
| import {task} from 'ember-concurrency'; | ||
|
|
||
| const YELLOW_THRESHOLD = 90 * 1024; // 90KB | ||
| const RED_THRESHOLD = 100 * 1024; // 100KB | ||
|
|
||
| // Rewritten URLs in real emails have a fixed format: | ||
| // {siteUrl}/r/{8-char-hex}?m={36-char-uuid} | ||
| // The path portion "/r/{8-char-hex}?m={36-char-uuid}" is always 50 characters | ||
| const REWRITTEN_URL_PATH_LENGTH = 50; | ||
|
|
||
| /** | ||
| * Service to fetch email size data for posts. | ||
| * Deduplicates API requests when multiple components request data for the same post version. | ||
| */ | ||
| export default class EmailSizeWarningService extends Service { | ||
| @service ajax; | ||
| @service ghostPaths; | ||
| @service store; | ||
|
|
||
| @inject config; | ||
|
|
||
| _newsletter = null; | ||
| _lastPostId = null; | ||
| _lastUpdatedAt = null; | ||
|
|
||
| /** | ||
| * Fetch email size data for a post. | ||
| * Returns existing task instance if one is running/completed for same post version. | ||
| * Multiple concurrent calls for the same post version will share a single API request. | ||
| * | ||
| * @param {Object} post - The post model | ||
| * @returns {Promise<{warningLevel: string|null, emailSizeKb: number|null}>} | ||
| */ | ||
| fetchEmailSize(post) { | ||
| if (!post?.id || post.isNew) { | ||
| return Promise.resolve({warningLevel: null, emailSizeKb: null}); | ||
| } | ||
|
|
||
| const postId = post.id; | ||
| const updatedAt = post.updatedAtUTC?.toISOString?.() || post.updatedAtUTC; | ||
|
|
||
| // Return existing task instance if we have one for this exact version | ||
| if (this._lastPostId === postId && this._lastUpdatedAt === updatedAt && this._fetchTask.last) { | ||
| return this._fetchTask.last; | ||
| } | ||
|
|
||
| this._lastPostId = postId; | ||
| this._lastUpdatedAt = updatedAt; | ||
|
|
||
| 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 -->'; | ||
|
|
||
| const startIndex = html.indexOf(contentStartMarker); | ||
| const endIndex = html.indexOf(contentEndMarker); | ||
|
|
||
| let contentHtml; | ||
| if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { | ||
| contentHtml = html.substring(startIndex + contentStartMarker.length, endIndex); | ||
| } else { | ||
| contentHtml = html; | ||
| } | ||
|
|
||
| const siteUrlLength = (this.config.blogUrl || '').length; | ||
| const rewrittenUrlLength = siteUrlLength + REWRITTEN_URL_PATH_LENGTH; | ||
|
|
||
| const hrefRegex = /href="([^"]+)"/g; | ||
| let totalAdjustment = 0; | ||
| let match; | ||
|
|
||
| while ((match = hrefRegex.exec(contentHtml)) !== null) { | ||
| const originalUrl = match[1]; | ||
|
|
||
| if (originalUrl.startsWith('%%{') && originalUrl.endsWith('}%%')) { | ||
| continue; | ||
| } | ||
| if (originalUrl === '#') { | ||
| continue; | ||
| } | ||
| if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://') && !originalUrl.startsWith('/')) { | ||
| continue; | ||
| } | ||
|
|
||
| totalAdjustment += rewrittenUrlLength - originalUrl.length; | ||
| } | ||
|
|
||
| return totalAdjustment; | ||
| } | ||
|
|
||
| @task | ||
| *_fetchTask(post) { | ||
| yield this._loadNewsletter(); | ||
| if (!this._newsletter) { | ||
| return {warningLevel: null, 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; | ||
|
|
||
| if (emailPreview?.html) { | ||
| const previewSizeBytes = new Blob([emailPreview.html]).size; | ||
| const linkAdjustment = this._calculateLinkRewritingAdjustment(emailPreview.html); | ||
| const estimatedSizeBytes = Math.max(0, previewSizeBytes + linkAdjustment); | ||
|
|
||
| const emailSizeKb = Math.round(estimatedSizeBytes / 1024); | ||
| let warningLevel = null; | ||
|
|
||
| if (estimatedSizeBytes >= RED_THRESHOLD) { | ||
| warningLevel = 'red'; | ||
| } else if (estimatedSizeBytes >= YELLOW_THRESHOLD) { | ||
| warningLevel = 'yellow'; | ||
| } | ||
|
|
||
| return {warningLevel, emailSizeKb}; | ||
| } | ||
| } catch (error) { | ||
| console.error('Email size check failed:', error); // eslint-disable-line no-console | ||
| } | ||
|
|
||
| return {warningLevel: null, emailSizeKb: null}; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: TryGhost/Ghost
Length of output: 269
🏁 Script executed:
Repository: TryGhost/Ghost
Length of output: 7424
🏁 Script executed:
Repository: TryGhost/Ghost
Length of output: 174
🏁 Script executed:
Repository: TryGhost/Ghost
Length of output: 1368
🏁 Script executed:
sed -n '431,520p' ./ghost/admin/app/styles/layouts/editor.cssRepository: TryGhost/Ghost
Length of output: 1937
Add keyboard focus and touch handlers to make the popup accessible.
The popup visibility relies on CSS hover state (
.gh-editor-email-size-warning:hover ~ .gh-editor-email-size-popup), which excludes keyboard users (Tab navigation) and touch users (no click handler on mobile).The accompanying JavaScript component must handle:
focus/blurstates to show/hide the popup on keyboard navigationclick/touchstarthandlers for mobile devicesaria-expanded,role="button",aria-label) on the trigger elementWithout these, keyboard and touch users cannot access the warning information.
🤖 Prompt for AI Agents