Skip to content

Commit 13655ac

Browse files
authored
Feat/pb 5241 marketing notifications (#383)
1 parent e65f8e0 commit 13655ac

14 files changed

Lines changed: 313 additions & 1 deletion

File tree

src/apps/main/interface.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AbsolutePath } from '../../context/local/localFile/infrastructure/Absol
1515
import { StoredValues } from './config/service.types';
1616
import { AppStore } from './config';
1717
import { ConfigTheme } from '../shared/types/Theme';
18+
import { UserNotification } from '../../infra/drive-server/out/dto';
1819

1920
/** This interface and declare global will replace the preload.d.ts.
2021
* The thing is that instead of that, we will gradually will be declaring the interface here as we generate tests
@@ -153,6 +154,7 @@ export interface IElectronAPI {
153154
getBackupErrorByFolder(folderId: number): Promise<BackupErrorRecord | undefined>;
154155
getLastBackupHadIssues(): Promise<boolean>;
155156
onBackupFatalErrorsChanged(fn: (backupErrors: Array<BackupErrorRecord>) => void): () => void;
157+
onMarketingNotifications(fn: (notifications: Array<UserNotification>) => void): () => void;
156158
getBackupFatalErrors(): Promise<Array<BackupErrorRecord>>;
157159
onBackupProgress(func: (value: number) => void): () => void;
158160
startRemoteSync(): Promise<void>;

src/apps/main/preload.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthLoginResponseViewModel } from '../../infra/drive-server/services/au
33
import { CleanerReport } from '../../backend/features/cleaner/cleaner.types';
44
import { BackupErrorRecord } from '../../backend/features/backup/backup.types';
55
import type { Device } from '../../backend/features/backup/types/Device';
6+
import type { UserNotification } from '../../infra/drive-server/out/dto';
67

78
declare interface Window {
89
electron: {
@@ -163,6 +164,7 @@ declare interface Window {
163164
discoveredBackups: () => Promise<void>;
164165
};
165166
onBackupFailed: (callback: (error: { message: string; cause: string }) => void) => () => void;
167+
onMarketingNotifications: (callback: (notifications: Array<UserNotification>) => void) => () => void;
166168

167169
antivirus: {
168170
isAvailable: () => Promise<boolean>;

src/apps/main/preload.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ contextBridge.exposeInMainWorld('electron', {
273273
return () => ipcRenderer.removeListener(eventName, callbackWrapper);
274274
},
275275
openUrl: (url) => {
276-
ipcRenderer.invoke('open-url', url);
276+
return ipcRenderer.invoke('open-url', url);
277277
},
278278
getPreferredAppLanguage() {
279279
return ipcRenderer.invoke('APP:PREFERRED_LANGUAGE');
@@ -291,6 +291,12 @@ contextBridge.exposeInMainWorld('electron', {
291291
ipcRenderer.on('backup-failed', (_, error) => callback(error));
292292
return () => ipcRenderer.removeListener('backup-failed', callback);
293293
},
294+
onMarketingNotifications(callback) {
295+
const eventName = 'marketing-notifications';
296+
const listener = (_, notifications) => callback(notifications);
297+
ipcRenderer.on(eventName, listener);
298+
return () => ipcRenderer.removeListener(eventName, listener);
299+
},
294300
antivirus: {
295301
/**
296302
* Check if antivirus feature is available for the current user

src/apps/renderer/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import IssuesPage from './pages/Issues/IssuesPage';
1010
import Settings from './pages/Settings';
1111
import Widget from './pages/Widget';
1212
import { useBackupNotifications } from './hooks/useBackupNotifications';
13+
import { useMarketingNotifications } from './hooks/useMarketingNotifications';
1314
import { useTheme } from './hooks/useTheme';
1415
import i18next from 'i18next';
1516
import { isLanguage } from '../shared/Locale/Language';
@@ -58,6 +59,7 @@ function Loader() {
5859

5960
export default function App() {
6061
useBackupNotifications();
62+
useMarketingNotifications();
6163
useTheme();
6264

6365
// Global IPC → i18next bridge for language changes
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { act, renderHook } from '@testing-library/react-hooks';
2+
import { useMarketingNotifications } from './useMarketingNotifications';
3+
4+
const notificationInstances: Array<{
5+
title: string;
6+
options?: NotificationOptions;
7+
onclick: ((event: Event) => void) | null;
8+
}> = [];
9+
10+
const NotificationMock = vi.fn(function NotificationMock(
11+
this: { title: string; options?: NotificationOptions; onclick: ((event: Event) => void) | null },
12+
title: string,
13+
options?: NotificationOptions,
14+
) {
15+
this.title = title;
16+
this.options = options;
17+
this.onclick = null;
18+
notificationInstances.push(this);
19+
});
20+
21+
const notification = {
22+
id: 'notification-id',
23+
link: 'https://internxt.com/promo',
24+
message: 'Promo message',
25+
expiresAt: null,
26+
createdAt: '2024-01-01T00:00:00.000Z',
27+
isRead: false,
28+
deliveredAt: '2024-01-01T00:00:00.000Z',
29+
readAt: null,
30+
};
31+
32+
describe('useMarketingNotifications', () => {
33+
beforeEach(() => {
34+
notificationInstances.length = 0;
35+
vi.mocked(window.electron.onMarketingNotifications).mockReturnValue(vi.fn());
36+
vi.mocked(window.electron.openUrl).mockResolvedValue(undefined);
37+
vi.stubGlobal('Notification', NotificationMock);
38+
});
39+
40+
afterEach(() => {
41+
vi.unstubAllGlobals();
42+
});
43+
44+
it('should subscribe to marketing notifications and clean up on unmount', () => {
45+
const removeListener = vi.fn();
46+
vi.mocked(window.electron.onMarketingNotifications).mockReturnValue(removeListener);
47+
48+
const { unmount } = renderHook(() => useMarketingNotifications());
49+
50+
expect(window.electron.onMarketingNotifications).toHaveBeenCalledWith(expect.any(Function));
51+
52+
unmount();
53+
54+
expect(removeListener).toHaveBeenCalledOnce();
55+
});
56+
57+
it('should show a native notification for each marketing notification', () => {
58+
renderHook(() => useMarketingNotifications());
59+
const listener = vi.mocked(window.electron.onMarketingNotifications).mock.calls[0][0];
60+
61+
act(() => {
62+
listener([notification]);
63+
});
64+
65+
expect(NotificationMock).toHaveBeenCalledWith(
66+
'Internxt Drive',
67+
expect.objectContaining({
68+
body: 'Promo message',
69+
icon: expect.stringContaining('256x256.png'),
70+
}),
71+
);
72+
});
73+
74+
it('should open the marketing link when the notification is clicked', async () => {
75+
renderHook(() => useMarketingNotifications());
76+
const listener = vi.mocked(window.electron.onMarketingNotifications).mock.calls[0][0];
77+
78+
act(() => {
79+
listener([notification]);
80+
});
81+
82+
await notificationInstances[0].onclick?.(new Event('click'));
83+
84+
expect(window.electron.openUrl).toHaveBeenCalledWith('https://internxt.com/promo');
85+
});
86+
87+
it('should not open non-https notification links', async () => {
88+
renderHook(() => useMarketingNotifications());
89+
const listener = vi.mocked(window.electron.onMarketingNotifications).mock.calls[0][0];
90+
91+
act(() => {
92+
listener([{ ...notification, link: 'internxt://notification/promo' }]);
93+
});
94+
95+
await notificationInstances[0].onclick?.(new Event('click'));
96+
97+
expect(window.electron.openUrl).not.toHaveBeenCalled();
98+
expect(window.electron.logger.error).toHaveBeenCalledWith(
99+
expect.objectContaining({
100+
msg: '[RENDERER] Error opening marketing notification link',
101+
link: 'internxt://notification/promo',
102+
}),
103+
);
104+
});
105+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useEffect } from 'react';
2+
import appIcon from '../../../../assets/icons/256x256.png';
3+
import type { UserNotification } from '../../../infra/drive-server/out/dto';
4+
5+
export function useMarketingNotifications() {
6+
useEffect(() => {
7+
const removeListener = window.electron.onMarketingNotifications((notifications) => {
8+
for (const notification of notifications) {
9+
showMarketingNotification(notification);
10+
}
11+
});
12+
13+
return () => {
14+
removeListener();
15+
};
16+
}, []);
17+
}
18+
19+
function showMarketingNotification(notification: UserNotification) {
20+
const popup = new Notification('Internxt Drive', {
21+
body: notification.message,
22+
icon: appIcon,
23+
});
24+
25+
popup.onclick = () => {
26+
void openNotificationLink(notification.link);
27+
};
28+
}
29+
30+
async function openNotificationLink(link: string): Promise<void> {
31+
try {
32+
const url = new URL(link);
33+
34+
if (url.protocol !== 'https:') {
35+
throw new Error('Unsupported marketing notification link protocol: ' + url.protocol);
36+
}
37+
38+
await window.electron.openUrl(url.toString());
39+
} catch (error) {
40+
window.electron.logger.error({ msg: '[RENDERER] Error opening marketing notification link', link, error });
41+
}
42+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { showMarketingNotifications } from './notifications/show-marketing-notifications';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { logger } from '@internxt/drive-desktop-core/build/backend';
2+
import { broadcastToWindows } from '../../../../apps/main/windows';
3+
import { getNotifications } from '../../../../infra/drive-server/services/notifications/get-notifications';
4+
import { showMarketingNotifications } from './show-marketing-notifications';
5+
6+
vi.mock('../../../../infra/drive-server/services/notifications/get-notifications', () => ({
7+
getNotifications: vi.fn(),
8+
}));
9+
10+
vi.mock('../../../../apps/main/windows', () => ({
11+
broadcastToWindows: vi.fn(),
12+
}));
13+
14+
describe('showMarketingNotifications', () => {
15+
const getNotificationsMock = vi.mocked(getNotifications);
16+
const broadcastToWindowsMock = vi.mocked(broadcastToWindows);
17+
18+
it('should broadcast marketing notifications to renderer windows', async () => {
19+
const notifications = [
20+
{
21+
id: 'first-notification',
22+
link: 'https://internxt.com/first',
23+
message: 'First message',
24+
expiresAt: null,
25+
createdAt: '2024-01-01T00:00:00.000Z',
26+
isRead: false,
27+
deliveredAt: '2024-01-01T00:00:00.000Z',
28+
readAt: null,
29+
},
30+
{
31+
id: 'second-notification',
32+
link: 'https://internxt.com/second',
33+
message: 'Second message',
34+
expiresAt: null,
35+
createdAt: '2024-01-01T00:00:00.000Z',
36+
isRead: false,
37+
deliveredAt: '2024-01-01T00:00:00.000Z',
38+
readAt: null,
39+
},
40+
];
41+
getNotificationsMock.mockResolvedValue({ data: notifications });
42+
43+
await showMarketingNotifications();
44+
45+
expect(broadcastToWindowsMock).toHaveBeenCalledWith('marketing-notifications', notifications);
46+
});
47+
48+
it('should not broadcast when there are no marketing notifications', async () => {
49+
getNotificationsMock.mockResolvedValue({ data: [] });
50+
51+
await showMarketingNotifications();
52+
53+
expect(broadcastToWindowsMock).not.toHaveBeenCalled();
54+
});
55+
56+
it('should log when fetching marketing notifications fails', async () => {
57+
const error = new Error('Request failed');
58+
getNotificationsMock.mockResolvedValue({ error });
59+
60+
await showMarketingNotifications();
61+
62+
expect(logger.error).toHaveBeenCalledWith({ msg: 'Error showing marketing notifications', error });
63+
});
64+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { logger } from '@internxt/drive-desktop-core/build/backend';
2+
import { broadcastToWindows } from '../../../../apps/main/windows';
3+
import { foldResult } from '../../../../context/shared/domain/Result';
4+
import { getNotifications } from '../../../../infra/drive-server/services/notifications/get-notifications';
5+
import type { UserNotification } from '../../../../infra/drive-server/out/dto';
6+
7+
const MARKETING_NOTIFICATIONS_EVENT = 'marketing-notifications';
8+
9+
export async function showMarketingNotifications() {
10+
const result = await getNotifications();
11+
12+
foldResult(result, {
13+
data: showNotifications,
14+
error: (error) => logger.error({ msg: 'Error showing marketing notifications', error }),
15+
});
16+
}
17+
18+
function showNotifications(notifications: Array<UserNotification>) {
19+
if (notifications.length === 0) {
20+
return;
21+
}
22+
23+
logger.debug({ msg: 'Forward marketing notifications to renderer', count: notifications.length });
24+
broadcastToWindows(MARKETING_NOTIFICATIONS_EVENT, notifications);
25+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
11
export type Result<T, E extends Error = Error> = { data: T; error?: undefined } | { error: E; data?: undefined };
2+
3+
export function foldResult<T, E extends Error, R>(
4+
result: Result<T, E>,
5+
cases: {
6+
data: (data: T) => R;
7+
error: (error: E) => R;
8+
},
9+
): R {
10+
return isError(result) ? cases.error(result.error) : cases.data(result.data);
11+
}
12+
13+
function isError<T, E extends Error>(result: Result<T, E>): result is { error: E } {
14+
return result.error !== undefined;
15+
}

0 commit comments

Comments
 (0)