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
2 changes: 2 additions & 0 deletions src/apps/main/interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -152,6 +153,7 @@ export interface IElectronAPI {
getBackupErrorByFolder(folderId: number): Promise<BackupErrorRecord | undefined>;
getLastBackupHadIssues(): Promise<boolean>;
onBackupFatalErrorsChanged(fn: (backupErrors: Array<BackupErrorRecord>) => void): () => void;
onMarketingNotifications(fn: (notifications: Array<UserNotification>) => void): () => void;
getBackupFatalErrors(): Promise<Array<BackupErrorRecord>>;
onBackupProgress(func: (value: number) => void): () => void;
startRemoteSync(): Promise<void>;
Expand Down
2 changes: 2 additions & 0 deletions src/apps/main/preload.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -163,6 +164,7 @@ declare interface Window {
discoveredBackups: () => Promise<void>;
};
onBackupFailed: (callback: (error: { message: string; cause: string }) => void) => () => void;
onMarketingNotifications: (callback: (notifications: Array<UserNotification>) => void) => () => void;

antivirus: {
isAvailable: () => Promise<boolean>;
Expand Down
8 changes: 7 additions & 1 deletion src/apps/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/apps/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,7 @@ function Loader() {

export default function App() {
useBackupNotifications();
useMarketingNotifications();
useTheme();

// Global IPC → i18next bridge for language changes
Expand Down
105 changes: 105 additions & 0 deletions src/apps/renderer/hooks/useMarketingNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
);
});
});
42 changes: 42 additions & 0 deletions src/apps/renderer/hooks/useMarketingNotifications.ts
Original file line number Diff line number Diff line change
@@ -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) => {

Check warning on line 7 in src/apps/renderer/hooks/useMarketingNotifications.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-desktop-linux&issues=AZ6YagM1Q4T98a8Fw1HP&open=AZ6YagM1Q4T98a8Fw1HP&pullRequest=383
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<void> {
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());

Check warning on line 38 in src/apps/renderer/hooks/useMarketingNotifications.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-desktop-linux&issues=AZ6YagM1Q4T98a8Fw1HQ&open=AZ6YagM1Q4T98a8Fw1HQ&pullRequest=383
} catch (error) {
window.electron.logger.error({ msg: '[RENDERER] Error opening marketing notification link', link, error });

Check warning on line 40 in src/apps/renderer/hooks/useMarketingNotifications.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-desktop-linux&issues=AZ6YagM1Q4T98a8Fw1HR&open=AZ6YagM1Q4T98a8Fw1HR&pullRequest=383
}
}
1 change: 1 addition & 0 deletions src/backend/features/marketing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { showMarketingNotifications } from './notifications/show-marketing-notifications';
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Original file line number Diff line number Diff line change
@@ -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<UserNotification>) {
if (notifications.length === 0) {
return;
}

logger.debug({ msg: 'Forward marketing notifications to renderer', count: notifications.length });
broadcastToWindows(MARKETING_NOTIFICATIONS_EVENT, notifications);
}
14 changes: 14 additions & 0 deletions src/context/shared/domain/Result.ts
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
export type Result<T, E extends Error = Error> = { data: T; error?: undefined } | { error: E; data?: undefined };

export function foldResult<T, E extends Error, R>(
result: Result<T, E>,
cases: {
data: (data: T) => R;
error: (error: E) => R;
},
): R {
return isError(result) ? cases.error(result.error) : cases.data(result.data);
}

function isError<T, E extends Error>(result: Result<T, E>): result is { error: E } {
return result.error !== undefined;
}
2 changes: 2 additions & 0 deletions src/core/bootstrap/register-session-event-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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:',
Expand Down
1 change: 1 addition & 0 deletions src/infra/drive-server/out/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Original file line number Diff line number Diff line change
@@ -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<Result<Array<UserNotification>, DriveServerError>> {
return await driveServerClient.GET('/notifications');
}
Loading