diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 26a8d5cc9b..1d5e7c872d 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -15,6 +15,7 @@ import { AbsolutePath } from '../../context/local/localFile/infrastructure/Absol import { StoredValues } from './config/service.types'; import { AppStore } from './config'; import { ConfigTheme } from '../shared/types/Theme'; +import { UserNotification } from '../../infra/drive-server/out/dto'; /** This interface and declare global will replace the preload.d.ts. * The thing is that instead of that, we will gradually will be declaring the interface here as we generate tests @@ -152,6 +153,7 @@ export interface IElectronAPI { getBackupErrorByFolder(folderId: number): Promise; getLastBackupHadIssues(): Promise; onBackupFatalErrorsChanged(fn: (backupErrors: Array) => void): () => void; + onMarketingNotifications(fn: (notifications: Array) => void): () => void; getBackupFatalErrors(): Promise>; onBackupProgress(func: (value: number) => void): () => void; startRemoteSync(): Promise; diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index 2a57746063..1a440efea5 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -3,6 +3,7 @@ import { AuthLoginResponseViewModel } from '../../infra/drive-server/services/au import { CleanerReport } from '../../backend/features/cleaner/cleaner.types'; import { BackupErrorRecord } from '../../backend/features/backup/backup.types'; import type { Device } from '../../backend/features/backup/types/Device'; +import type { UserNotification } from '../../infra/drive-server/out/dto'; declare interface Window { electron: { @@ -163,6 +164,7 @@ declare interface Window { discoveredBackups: () => Promise; }; onBackupFailed: (callback: (error: { message: string; cause: string }) => void) => () => void; + onMarketingNotifications: (callback: (notifications: Array) => void) => () => void; antivirus: { isAvailable: () => Promise; diff --git a/src/apps/main/preload.js b/src/apps/main/preload.js index 170be45c3c..58b7ba486f 100644 --- a/src/apps/main/preload.js +++ b/src/apps/main/preload.js @@ -273,7 +273,7 @@ contextBridge.exposeInMainWorld('electron', { return () => ipcRenderer.removeListener(eventName, callbackWrapper); }, openUrl: (url) => { - ipcRenderer.invoke('open-url', url); + return ipcRenderer.invoke('open-url', url); }, getPreferredAppLanguage() { return ipcRenderer.invoke('APP:PREFERRED_LANGUAGE'); @@ -291,6 +291,12 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.on('backup-failed', (_, error) => callback(error)); return () => ipcRenderer.removeListener('backup-failed', callback); }, + onMarketingNotifications(callback) { + const eventName = 'marketing-notifications'; + const listener = (_, notifications) => callback(notifications); + ipcRenderer.on(eventName, listener); + return () => ipcRenderer.removeListener(eventName, listener); + }, antivirus: { /** * Check if antivirus feature is available for the current user diff --git a/src/apps/renderer/App.tsx b/src/apps/renderer/App.tsx index 495529abd9..4704580df6 100644 --- a/src/apps/renderer/App.tsx +++ b/src/apps/renderer/App.tsx @@ -10,6 +10,7 @@ import IssuesPage from './pages/Issues/IssuesPage'; import Settings from './pages/Settings'; import Widget from './pages/Widget'; import { useBackupNotifications } from './hooks/useBackupNotifications'; +import { useMarketingNotifications } from './hooks/useMarketingNotifications'; import { useTheme } from './hooks/useTheme'; import i18next from 'i18next'; import { isLanguage } from '../shared/Locale/Language'; @@ -52,6 +53,7 @@ function Loader() { export default function App() { useBackupNotifications(); + useMarketingNotifications(); useTheme(); // Global IPC → i18next bridge for language changes diff --git a/src/apps/renderer/hooks/useMarketingNotifications.test.ts b/src/apps/renderer/hooks/useMarketingNotifications.test.ts new file mode 100644 index 0000000000..cd40e0c2ce --- /dev/null +++ b/src/apps/renderer/hooks/useMarketingNotifications.test.ts @@ -0,0 +1,105 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useMarketingNotifications } from './useMarketingNotifications'; + +const notificationInstances: Array<{ + title: string; + options?: NotificationOptions; + onclick: ((event: Event) => void) | null; +}> = []; + +const NotificationMock = vi.fn(function NotificationMock( + this: { title: string; options?: NotificationOptions; onclick: ((event: Event) => void) | null }, + title: string, + options?: NotificationOptions, +) { + this.title = title; + this.options = options; + this.onclick = null; + notificationInstances.push(this); +}); + +const notification = { + id: 'notification-id', + link: 'https://internxt.com/promo', + message: 'Promo message', + expiresAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + isRead: false, + deliveredAt: '2024-01-01T00:00:00.000Z', + readAt: null, +}; + +describe('useMarketingNotifications', () => { + beforeEach(() => { + notificationInstances.length = 0; + vi.mocked(window.electron.onMarketingNotifications).mockReturnValue(vi.fn()); + vi.mocked(window.electron.openUrl).mockResolvedValue(undefined); + vi.stubGlobal('Notification', NotificationMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should subscribe to marketing notifications and clean up on unmount', () => { + const removeListener = vi.fn(); + vi.mocked(window.electron.onMarketingNotifications).mockReturnValue(removeListener); + + const { unmount } = renderHook(() => useMarketingNotifications()); + + expect(window.electron.onMarketingNotifications).toHaveBeenCalledWith(expect.any(Function)); + + unmount(); + + expect(removeListener).toHaveBeenCalledOnce(); + }); + + it('should show a native notification for each marketing notification', () => { + renderHook(() => useMarketingNotifications()); + const listener = vi.mocked(window.electron.onMarketingNotifications).mock.calls[0][0]; + + act(() => { + listener([notification]); + }); + + expect(NotificationMock).toHaveBeenCalledWith( + 'Internxt Drive', + expect.objectContaining({ + body: 'Promo message', + icon: expect.stringContaining('256x256.png'), + }), + ); + }); + + it('should open the marketing link when the notification is clicked', async () => { + renderHook(() => useMarketingNotifications()); + const listener = vi.mocked(window.electron.onMarketingNotifications).mock.calls[0][0]; + + act(() => { + listener([notification]); + }); + + await notificationInstances[0].onclick?.(new Event('click')); + + expect(window.electron.openUrl).toHaveBeenCalledWith('https://internxt.com/promo'); + }); + + it('should not open non-https notification links', async () => { + renderHook(() => useMarketingNotifications()); + const listener = vi.mocked(window.electron.onMarketingNotifications).mock.calls[0][0]; + + act(() => { + listener([{ ...notification, link: 'internxt://notification/promo' }]); + }); + + await notificationInstances[0].onclick?.(new Event('click')); + + expect(window.electron.openUrl).not.toHaveBeenCalled(); + expect(window.electron.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + msg: '[RENDERER] Error opening marketing notification link', + link: 'internxt://notification/promo', + }), + ); + }); +}); diff --git a/src/apps/renderer/hooks/useMarketingNotifications.ts b/src/apps/renderer/hooks/useMarketingNotifications.ts new file mode 100644 index 0000000000..fa4733d887 --- /dev/null +++ b/src/apps/renderer/hooks/useMarketingNotifications.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import appIcon from '../../../../assets/icons/256x256.png'; +import type { UserNotification } from '../../../infra/drive-server/out/dto'; + +export function useMarketingNotifications() { + useEffect(() => { + const removeListener = window.electron.onMarketingNotifications((notifications) => { + for (const notification of notifications) { + showMarketingNotification(notification); + } + }); + + return () => { + removeListener(); + }; + }, []); +} + +function showMarketingNotification(notification: UserNotification) { + const popup = new Notification('Internxt Drive', { + body: notification.message, + icon: appIcon, + }); + + popup.onclick = () => { + void openNotificationLink(notification.link); + }; +} + +async function openNotificationLink(link: string): Promise { + try { + const url = new URL(link); + + if (url.protocol !== 'https:') { + throw new Error('Unsupported marketing notification link protocol: ' + url.protocol); + } + + await window.electron.openUrl(url.toString()); + } catch (error) { + window.electron.logger.error({ msg: '[RENDERER] Error opening marketing notification link', link, error }); + } +} diff --git a/src/backend/features/marketing/index.ts b/src/backend/features/marketing/index.ts new file mode 100644 index 0000000000..7e82464628 --- /dev/null +++ b/src/backend/features/marketing/index.ts @@ -0,0 +1 @@ +export { showMarketingNotifications } from './notifications/show-marketing-notifications'; diff --git a/src/backend/features/marketing/notifications/show-marketing-notifications.test.ts b/src/backend/features/marketing/notifications/show-marketing-notifications.test.ts new file mode 100644 index 0000000000..84495a5574 --- /dev/null +++ b/src/backend/features/marketing/notifications/show-marketing-notifications.test.ts @@ -0,0 +1,64 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { broadcastToWindows } from '../../../../apps/main/windows'; +import { getNotifications } from '../../../../infra/drive-server/services/notifications/get-notifications'; +import { showMarketingNotifications } from './show-marketing-notifications'; + +vi.mock('../../../../infra/drive-server/services/notifications/get-notifications', () => ({ + getNotifications: vi.fn(), +})); + +vi.mock('../../../../apps/main/windows', () => ({ + broadcastToWindows: vi.fn(), +})); + +describe('showMarketingNotifications', () => { + const getNotificationsMock = vi.mocked(getNotifications); + const broadcastToWindowsMock = vi.mocked(broadcastToWindows); + + it('should broadcast marketing notifications to renderer windows', async () => { + const notifications = [ + { + id: 'first-notification', + link: 'https://internxt.com/first', + message: 'First message', + expiresAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + isRead: false, + deliveredAt: '2024-01-01T00:00:00.000Z', + readAt: null, + }, + { + id: 'second-notification', + link: 'https://internxt.com/second', + message: 'Second message', + expiresAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + isRead: false, + deliveredAt: '2024-01-01T00:00:00.000Z', + readAt: null, + }, + ]; + getNotificationsMock.mockResolvedValue({ data: notifications }); + + await showMarketingNotifications(); + + expect(broadcastToWindowsMock).toHaveBeenCalledWith('marketing-notifications', notifications); + }); + + it('should not broadcast when there are no marketing notifications', async () => { + getNotificationsMock.mockResolvedValue({ data: [] }); + + await showMarketingNotifications(); + + expect(broadcastToWindowsMock).not.toHaveBeenCalled(); + }); + + it('should log when fetching marketing notifications fails', async () => { + const error = new Error('Request failed'); + getNotificationsMock.mockResolvedValue({ error }); + + await showMarketingNotifications(); + + expect(logger.error).toHaveBeenCalledWith({ msg: 'Error showing marketing notifications', error }); + }); +}); diff --git a/src/backend/features/marketing/notifications/show-marketing-notifications.ts b/src/backend/features/marketing/notifications/show-marketing-notifications.ts new file mode 100644 index 0000000000..09a0835611 --- /dev/null +++ b/src/backend/features/marketing/notifications/show-marketing-notifications.ts @@ -0,0 +1,25 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { broadcastToWindows } from '../../../../apps/main/windows'; +import { foldResult } from '../../../../context/shared/domain/Result'; +import { getNotifications } from '../../../../infra/drive-server/services/notifications/get-notifications'; +import type { UserNotification } from '../../../../infra/drive-server/out/dto'; + +const MARKETING_NOTIFICATIONS_EVENT = 'marketing-notifications'; + +export async function showMarketingNotifications() { + const result = await getNotifications(); + + foldResult(result, { + data: showNotifications, + error: (error) => logger.error({ msg: 'Error showing marketing notifications', error }), + }); +} + +function showNotifications(notifications: Array) { + if (notifications.length === 0) { + return; + } + + logger.debug({ msg: 'Forward marketing notifications to renderer', count: notifications.length }); + broadcastToWindows(MARKETING_NOTIFICATIONS_EVENT, notifications); +} diff --git a/src/context/shared/domain/Result.ts b/src/context/shared/domain/Result.ts index d08aa4455b..ed721d75a4 100644 --- a/src/context/shared/domain/Result.ts +++ b/src/context/shared/domain/Result.ts @@ -1 +1,15 @@ export type Result = { data: T; error?: undefined } | { error: E; data?: undefined }; + +export function foldResult( + result: Result, + cases: { + data: (data: T) => R; + error: (error: E) => R; + }, +): R { + return isError(result) ? cases.error(result.error) : cases.data(result.data); +} + +function isError(result: Result): result is { error: E } { + return result.error !== undefined; +} diff --git a/src/core/bootstrap/register-session-event-handlers.ts b/src/core/bootstrap/register-session-event-handlers.ts index c2a783e0b0..26632af6e1 100644 --- a/src/core/bootstrap/register-session-event-handlers.ts +++ b/src/core/bootstrap/register-session-event-handlers.ts @@ -14,6 +14,7 @@ import { registerBackupHandlers } from '../../backend/features/backup/register-b import { startBackupsIfAvailable } from '../../backend/features/backup/start-backups-if-available'; import { stopVirtualDrive } from '../../backend/features/virtual-drive/services/virtual-drive.service'; import { resolveUserFileSizeLimit } from '../../backend/features/user/file-size-limit/resolve-user-file-size-limit'; +import { showMarketingNotifications } from '../../backend/features/marketing'; function onWidgetIsReady() { registerBackupHandlers(); @@ -55,6 +56,7 @@ async function onUserLoggedIn() { await resolveUserFileSizeLimit(); await getUserAvailableProductsAndStore(); await trySetupAntivirusIpcAndInitialize(); + void showMarketingNotifications(); } catch (error) { logger.error({ msg: 'Error on main process while handling USER_LOGGED_IN event:', diff --git a/src/infra/drive-server/out/dto.ts b/src/infra/drive-server/out/dto.ts index 8e81e2a2f2..f06929ba81 100644 --- a/src/infra/drive-server/out/dto.ts +++ b/src/infra/drive-server/out/dto.ts @@ -11,3 +11,4 @@ export type ThumbnailDto = components['schemas']['ThumbnailDto']; export type GetFolderContentDto = components['schemas']['GetFolderContentDto']; export type UserFileSizeLimit = components['schemas']['GetFileLimitsDto']['maxUploadFileSize']; +export type UserNotification = components['schemas']['NotificationWithStatusDto']; diff --git a/src/infra/drive-server/services/notifications/get-notifications.test.ts b/src/infra/drive-server/services/notifications/get-notifications.test.ts new file mode 100644 index 0000000000..b1963807f9 --- /dev/null +++ b/src/infra/drive-server/services/notifications/get-notifications.test.ts @@ -0,0 +1,38 @@ +import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { driveServerClient } from '../../client/drive-server.client.instance'; +import { DriveServerError } from '../../drive-server.error'; +import { getNotifications } from './get-notifications'; + +describe('getNotifications', () => { + const driveServerGetMock = partialSpyOn(driveServerClient, 'GET'); + + it('should fetch user notifications', async () => { + const notifications = [ + { + id: 'notification-id', + link: 'https://internxt.com/promotions/black-friday', + message: 'Black Friday Sale - 50% off all plans!', + expiresAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + isRead: false, + deliveredAt: '2024-01-01T00:00:00.000Z', + readAt: null, + }, + ]; + driveServerGetMock.mockResolvedValue({ data: notifications } as object); + + const result = await getNotifications(); + + expect(driveServerGetMock).toHaveBeenCalledWith('/notifications'); + expect(result).toStrictEqual({ data: notifications }); + }); + + it('should return the drive server error when the request fails', async () => { + const error = new DriveServerError('UNKNOWN', 500); + driveServerGetMock.mockResolvedValue({ error } as object); + + const result = await getNotifications(); + + expect(result).toStrictEqual({ error }); + }); +}); diff --git a/src/infra/drive-server/services/notifications/get-notifications.ts b/src/infra/drive-server/services/notifications/get-notifications.ts new file mode 100644 index 0000000000..3b2825211d --- /dev/null +++ b/src/infra/drive-server/services/notifications/get-notifications.ts @@ -0,0 +1,8 @@ +import { Result } from '../../../../context/shared/domain/Result'; +import { driveServerClient } from '../../client/drive-server.client.instance'; +import { DriveServerError } from '../../drive-server.error'; +import { UserNotification } from '../../out/dto'; + +export async function getNotifications(): Promise, DriveServerError>> { + return await driveServerClient.GET('/notifications'); +}