Skip to content

Commit 0d5dc23

Browse files
refactor: [SDK-4535] update bell dialog logic (#1461)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3a6c069 commit 0d5dc23

5 files changed

Lines changed: 126 additions & 24 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
},
7878
{
7979
"path": "./build/releases/OneSignalSDK.page.es6.js",
80-
"limit": "42.285 kB",
80+
"limit": "42.32 kB",
8181
"gzip": true
8282
},
8383
{

src/page/bell/Dialog.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,87 @@ describe('Dialog', () => {
161161
expect(body.querySelector('img')).toBeNull();
162162
});
163163

164+
// SDK-4535
165+
describe('text rendering', () => {
166+
const RAW_INPUT = 'Notifications &amp; Updates <img src=a>';
167+
const EXPECTED_TEXT = 'Notifications & Updates ';
168+
169+
test('renders dialog.main.title with entities decoded and tags stripped', async () => {
170+
isPushEnabledSpy.mockResolvedValue(false);
171+
const bell = new Bell({ enable: false });
172+
bell._state = BellState._Unsubscribed;
173+
bell._options.text['dialog.main.title'] = RAW_INPUT;
174+
const dialog = new Dialog(bell);
175+
176+
await dialog._updateContent();
177+
178+
const body = dialog._element!.querySelector('.onesignal-bell-launcher-dialog-body')!;
179+
expect(body.querySelector('h1')?.textContent).toBe(EXPECTED_TEXT);
180+
expect(body.querySelector('h1 img')).toBeNull();
181+
});
182+
183+
test('renders dialog.main.button.subscribe with entities decoded and tags stripped', async () => {
184+
isPushEnabledSpy.mockResolvedValue(false);
185+
const bell = new Bell({ enable: false });
186+
bell._state = BellState._Unsubscribed;
187+
bell._options.text['dialog.main.button.subscribe'] = RAW_INPUT;
188+
const dialog = new Dialog(bell);
189+
190+
await dialog._updateContent();
191+
192+
expect(dialog._subscribeButton?.textContent).toBe(EXPECTED_TEXT);
193+
expect(dialog._subscribeButton?.querySelector('img')).toBeNull();
194+
});
195+
196+
test('renders dialog.blocked.title with entities decoded and tags stripped', async () => {
197+
isPushEnabledSpy.mockResolvedValue(false);
198+
vi.spyOn(detect, 'getBrowserName').mockReturnValue(Browser._Chrome);
199+
vi.spyOn(detect, 'isMobileBrowser').mockReturnValue(false);
200+
vi.spyOn(detect, 'isTabletBrowser').mockReturnValue(false);
201+
const bell = new Bell({ enable: false });
202+
bell._state = BellState._Blocked;
203+
bell._options.text['dialog.blocked.title'] = RAW_INPUT;
204+
const dialog = new Dialog(bell);
205+
206+
await dialog._updateContent();
207+
208+
const body = dialog._element!.querySelector('.onesignal-bell-launcher-dialog-body')!;
209+
expect(body.querySelector('h1')?.textContent).toBe(EXPECTED_TEXT);
210+
expect(body.querySelector('h1 img')).toBeNull();
211+
});
212+
213+
test('icon URL with special chars stays inside src attribute', async () => {
214+
isPushEnabledSpy.mockResolvedValue(false);
215+
vi.spyOn(utils, 'getPlatformNotificationIcon').mockReturnValue('icon.png" extra="x');
216+
const bell = new Bell({ enable: false });
217+
bell._state = BellState._Unsubscribed;
218+
const dialog = new Dialog(bell);
219+
220+
await dialog._updateContent();
221+
222+
const img = dialog._element!.querySelector('.push-notification-icon img');
223+
expect(img?.getAttribute('src')).toBe('icon.png" extra="x');
224+
expect(img?.hasAttribute('extra')).toBe(false);
225+
});
226+
227+
test('renders dialog.blocked.message with entities decoded and tags stripped', async () => {
228+
isPushEnabledSpy.mockResolvedValue(false);
229+
vi.spyOn(detect, 'getBrowserName').mockReturnValue(Browser._Chrome);
230+
vi.spyOn(detect, 'isMobileBrowser').mockReturnValue(false);
231+
vi.spyOn(detect, 'isTabletBrowser').mockReturnValue(false);
232+
const bell = new Bell({ enable: false });
233+
bell._state = BellState._Blocked;
234+
bell._options.text['dialog.blocked.message'] = RAW_INPUT;
235+
const dialog = new Dialog(bell);
236+
237+
await dialog._updateContent();
238+
239+
const body = dialog._element!.querySelector('.onesignal-bell-launcher-dialog-body')!;
240+
expect(body.querySelector('.instructions p')?.textContent).toBe(EXPECTED_TEXT);
241+
expect(body.querySelector('.instructions p img')).toBeNull();
242+
});
243+
});
244+
164245
test('_updateContent includes credit footer when showCredit is true', async () => {
165246
isPushEnabledSpy.mockResolvedValue(false);
166247
const bell = new Bell({ enable: false });

src/page/bell/Dialog.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addDomElement, clearDomElementChildren } from 'src/shared/helpers/dom';
1+
import { addDomElement, clearDomElementChildren, decodeHtmlEntities } from 'src/shared/helpers/dom';
22
import type { NotificationIcons } from 'src/shared/notifications/types';
33
import { Browser } from 'src/shared/useragent/constants';
44
import { getBrowserName, isMobileBrowser, isTabletBrowser } from 'src/shared/useragent/detect';
@@ -19,6 +19,8 @@ const UNBLOCK_IMAGES: Partial<Record<string, string>> = {
1919
[Browser._Edge]: '/bell/edge-unblock.png',
2020
};
2121

22+
const BODY_SELECTOR = '.onesignal-bell-launcher-dialog-body';
23+
2224
export default class Dialog {
2325
public _bell: Bell;
2426
public _notificationIcons: NotificationIcons | null = null;
@@ -53,27 +55,27 @@ export default class Dialog {
5355
async _updateContent() {
5456
const isEnabled = await OneSignal._context._subscriptionManager._isPushNotificationsEnabled();
5557

56-
const bodySelector = '.onesignal-bell-launcher-dialog-body';
57-
clearDomElementChildren(bodySelector);
58+
const body = clearDomElementChildren(BODY_SELECTOR);
5859

5960
const text = this._bell._options.text;
60-
const footer = this._bell._options.showCredit
61-
? `<div class="divider"></div><div class="kickback">Powered by <a href="https://onesignal.com" class="kickback" target="_blank">OneSignal</a></div>`
62-
: '';
63-
64-
let contents = 'Nothing to show.';
6561
const state = this._bell._state;
6662
const stateMatchesSubscription =
6763
(state === BellState._Subscribed && isEnabled) ||
6864
(state === BellState._Unsubscribed && !isEnabled);
6965

7066
if (stateMatchesSubscription) {
71-
contents = this._buildSubscriptionContent(text, footer);
67+
this._renderSubscription(body, text);
7268
} else if (state === BellState._Blocked) {
73-
contents = this._buildBlockedContent(text, footer);
69+
this._renderBlocked(body, text);
7470
}
7571

76-
addDomElement(bodySelector, 'beforeend', contents);
72+
if (this._bell._options.showCredit) {
73+
addDomElement(
74+
body,
75+
'beforeend',
76+
`<div class="divider"></div><div class="kickback">Powered by <a href="https://onesignal.com" class="kickback" target="_blank">OneSignal</a></div>`,
77+
);
78+
}
7779

7880
this._subscribeButton?.addEventListener('click', () => {
7981
OneSignal._doNotShowWelcomeNotification = false;
@@ -82,11 +84,12 @@ export default class Dialog {
8284
this._unsubscribeButton?.addEventListener('click', () => this._bell._onUnsubscribeClick());
8385
}
8486

85-
private _buildSubscriptionContent(text: Bell['_options']['text'], footer: string): string {
87+
private _renderSubscription(body: Element, text: Bell['_options']['text']): void {
8688
const imageUrl = getPlatformNotificationIcon(this._notificationIcons);
89+
8790
const iconHtml =
8891
imageUrl !== 'default-icon'
89-
? `<div class="push-notification-icon"><img src="${imageUrl}"></div>`
92+
? `<div class="push-notification-icon"><img></div>`
9093
: `<div class="push-notification-icon push-notification-icon-default"></div>`;
9194

9295
const isSubscribed = this._bell._state === BellState._Subscribed;
@@ -95,27 +98,43 @@ export default class Dialog {
9598
? text['dialog.main.button.unsubscribe']
9699
: text['dialog.main.button.subscribe'];
97100

98-
return `<h1>${text['dialog.main.title']}</h1><div class="divider"></div><div class="push-notification">${iconHtml}<div class="push-notification-text-container"><div class="push-notification-text push-notification-text-short"></div><div class="push-notification-text"></div><div class="push-notification-text push-notification-text-medium"></div><div class="push-notification-text"></div><div class="push-notification-text push-notification-text-medium"></div></div></div><div class="action-container"><button type="button" class="action" id="${buttonId}">${buttonText}</button></div>${footer}`;
101+
addDomElement(
102+
body,
103+
'beforeend',
104+
`<h1></h1><div class="divider"></div><div class="push-notification">${iconHtml}<div class="push-notification-text-container"><div class="push-notification-text push-notification-text-short"></div><div class="push-notification-text"></div><div class="push-notification-text push-notification-text-medium"></div><div class="push-notification-text"></div><div class="push-notification-text push-notification-text-medium"></div></div></div><div class="action-container"><button type="button" class="action" id="${buttonId}"></button></div>`,
105+
);
106+
107+
body.querySelector('h1')!.textContent = decodeHtmlEntities(text['dialog.main.title']);
108+
body.querySelector(`#${buttonId}`)!.textContent = decodeHtmlEntities(buttonText);
109+
if (imageUrl !== 'default-icon') {
110+
body.querySelector('.push-notification-icon img')!.setAttribute('src', imageUrl);
111+
}
99112
}
100113

101-
private _buildBlockedContent(text: Bell['_options']['text'], footer: string): string {
114+
private _renderBlocked(body: Element, text: Bell['_options']['text']): void {
102115
const browserName = getBrowserName();
103116
const isMobileOrTablet = isMobileBrowser() || isTabletBrowser();
104117

105118
let instructionsHtml = '';
106119
if (isMobileOrTablet && browserName === Browser._Chrome) {
107120
instructionsHtml = `<ol><li>Access <strong>Settings</strong> by tapping the three menu dots <strong>⋮</strong></li><li>Click <strong>Site settings</strong> under Advanced.</li><li>Click <strong>Notifications</strong>.</li><li>Find and click this entry for this website.</li><li>Click <strong>Notifications</strong> and set it to <strong>Allow</strong>.</li></ol>`;
108121
} else {
109-
const imagePath =
110-
browserName === Browser._Chrome && isMobileOrTablet
111-
? undefined
112-
: UNBLOCK_IMAGES[browserName];
122+
const imagePath = UNBLOCK_IMAGES[browserName];
113123
if (imagePath) {
114124
const imageUrl = STATIC_RESOURCES_URL + imagePath;
115125
instructionsHtml = `<a href="${imageUrl}" target="_blank"><img src="${imageUrl}"></a>`;
116126
}
117127
}
118128

119-
return `<h1>${text['dialog.blocked.title']}</h1><div class="divider"></div><div class="instructions"><p>${text['dialog.blocked.message']}</p>${instructionsHtml}</div>${footer}`;
129+
addDomElement(
130+
body,
131+
'beforeend',
132+
`<h1></h1><div class="divider"></div><div class="instructions"><p></p>${instructionsHtml}</div>`,
133+
);
134+
135+
body.querySelector('h1')!.textContent = decodeHtmlEntities(text['dialog.blocked.title']);
136+
body.querySelector('.instructions p')!.textContent = decodeHtmlEntities(
137+
text['dialog.blocked.message'],
138+
);
120139
}
121140
}

src/shared/config/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function isSlidedownConfigVersion1(
145145
}
146146

147147
function convertConfigToVersionTwo(
148-
slidedownConfig: SlidedownOptionsVersion1 & SlidedownOptions,
148+
slidedownConfig: SlidedownOptionsVersion1 & Partial<SlidedownOptions>,
149149
): SlidedownOptions {
150150
const isCategory = isCategorySlidedownConfiguredVersion1(slidedownConfig);
151151
const promptType = isCategory ? DelayedPromptType._Category : DelayedPromptType._Push;

src/shared/helpers/dom.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ export function removeDomElement(selector: string) {
2121
}
2222
}
2323

24-
export function clearDomElementChildren(target: Element | string) {
25-
resolveElement(target).replaceChildren();
24+
export function clearDomElementChildren(target: Element | string): Element {
25+
const el = resolveElement(target);
26+
el.replaceChildren();
27+
return el;
2628
}
2729

2830
export function getDomElementOrStub(selector: string): Element {

0 commit comments

Comments
 (0)