diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart index 436d2d190f..b5885aa441 100644 --- a/mobile/openapi/lib/model/notification_type.dart +++ b/mobile/openapi/lib/model/notification_type.dart @@ -26,6 +26,8 @@ class NotificationType { static const jobFailed = NotificationType._(r'JobFailed'); static const backupFailed = NotificationType._(r'BackupFailed'); static const systemMessage = NotificationType._(r'SystemMessage'); + static const albumInvite = NotificationType._(r'AlbumInvite'); + static const albumUpdate = NotificationType._(r'AlbumUpdate'); static const custom = NotificationType._(r'Custom'); /// List of all possible values in this [enum][NotificationType]. @@ -33,6 +35,8 @@ class NotificationType { jobFailed, backupFailed, systemMessage, + albumInvite, + albumUpdate, custom, ]; @@ -75,6 +79,8 @@ class NotificationTypeTypeTransformer { case r'JobFailed': return NotificationType.jobFailed; case r'BackupFailed': return NotificationType.backupFailed; case r'SystemMessage': return NotificationType.systemMessage; + case r'AlbumInvite': return NotificationType.albumInvite; + case r'AlbumUpdate': return NotificationType.albumUpdate; case r'Custom': return NotificationType.custom; default: if (!allowNull) { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b574bc6624..5eada143a6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12820,6 +12820,8 @@ "JobFailed", "BackupFailed", "SystemMessage", + "AlbumInvite", + "AlbumUpdate", "Custom" ], "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c8a69dfe8c..4d3d0cd21d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4650,6 +4650,8 @@ export enum NotificationType { JobFailed = "JobFailed", BackupFailed = "BackupFailed", SystemMessage = "SystemMessage", + AlbumInvite = "AlbumInvite", + AlbumUpdate = "AlbumUpdate", Custom = "Custom" } export enum UserStatus { diff --git a/server/src/enum.ts b/server/src/enum.ts index 646138b060..b8e6e5209f 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -722,6 +722,8 @@ export enum NotificationType { JobFailed = 'JobFailed', BackupFailed = 'BackupFailed', SystemMessage = 'SystemMessage', + AlbumInvite = 'AlbumInvite', + AlbumUpdate = 'AlbumUpdate', Custom = 'Custom', } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 11c385b1e2..a96caf5ac2 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -7,6 +7,7 @@ import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; +import { notificationStub } from 'test/fixtures/notification.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -282,6 +283,7 @@ describe(NotificationService.name, () => { }, ], }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); }); @@ -297,6 +299,7 @@ describe(NotificationService.name, () => { }, ], }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); }); @@ -313,6 +316,7 @@ describe(NotificationService.name, () => { ], }); mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); @@ -334,6 +338,7 @@ describe(NotificationService.name, () => { ], }); mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); @@ -363,6 +368,7 @@ describe(NotificationService.name, () => { ], }); mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([ { id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' }, @@ -394,6 +400,7 @@ describe(NotificationService.name, () => { ], }); mocks.systemMetadata.get.mockResolvedValue({ server: {} }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]); @@ -431,6 +438,7 @@ describe(NotificationService.name, () => { albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValueOnce(userStub.user1); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); @@ -453,6 +461,7 @@ describe(NotificationService.name, () => { }, ], }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); @@ -475,6 +484,7 @@ describe(NotificationService.name, () => { }, ], }); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); @@ -489,6 +499,7 @@ describe(NotificationService.name, () => { albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue(userStub.user1); + mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 91a043d405..5d192523b1 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; +import { MapAlbumDto } from 'src/dtos/album.dto'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -295,6 +296,8 @@ export class NotificationService extends BaseService { return JobStatus.Skipped; } + await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, album.owner.name); + const { emailNotifications } = getPreferences(recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { @@ -344,6 +347,8 @@ export class NotificationService extends BaseService { return JobStatus.Skipped; } + await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumUpdate); + const attachment = await this.getAlbumThumbnailAttachment(album); const { server, templates } = await this.getConfig({ withCache: false }); @@ -431,4 +436,25 @@ export class NotificationService extends BaseService { cid: 'album-thumbnail', }; } + + private async sendAlbumLocalNotification( + album: MapAlbumDto, + userId: string, + type: NotificationType.AlbumInvite | NotificationType.AlbumUpdate, + senderName?: string, + ) { + const isInvite = type === NotificationType.AlbumInvite; + const item = await this.notificationRepository.create({ + userId, + type, + level: isInvite ? NotificationLevel.Success : NotificationLevel.Info, + title: isInvite ? 'Shared Album Invitation' : 'Shared Album Update', + description: isInvite + ? `${senderName} shared an album (${album.albumName}) with you` + : `New media has been added to the album (${album.albumName})`, + data: JSON.stringify({ albumId: album.id }), + }); + + this.eventRepository.clientSend('on_notification', userId, mapNotification(item)); + } } diff --git a/server/test/fixtures/notification.stub.ts b/server/test/fixtures/notification.stub.ts new file mode 100644 index 0000000000..b5a436a622 --- /dev/null +++ b/server/test/fixtures/notification.stub.ts @@ -0,0 +1,14 @@ +import { NotificationLevel, NotificationType } from 'src/enum'; + +export const notificationStub = { + albumEvent: { + id: 'notification-album-event', + type: NotificationType.AlbumInvite, + description: 'You have been invited to a shared album', + title: 'Album Invitation', + createdAt: new Date('2024-01-01'), + data: { albumId: 'album-id' }, + level: NotificationLevel.Success, + readAt: null, + }, +}; diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index e4446cf142..51488381f5 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -19,6 +19,7 @@ import { user } from '$lib/stores/user.store'; import { Button, IconButton } from '@immich/ui'; import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; + import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; @@ -37,6 +38,14 @@ let shouldShowNotificationPanel = $state(false); let innerWidth: number = $state(0); const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0); + + onMount(async () => { + try { + await notificationManager.refresh(); + } catch (error) { + console.error('Failed to load notifications on mount', error); + } + }); @@ -125,15 +134,25 @@ onEscape: () => (shouldShowNotificationPanel = false), }} > - (shouldShowNotificationPanel = !shouldShowNotificationPanel)} - aria-label={$t('notifications')} - /> +
+ (shouldShowNotificationPanel = !shouldShowNotificationPanel)} + aria-label={$t('notifications')} + /> + + {#if hasUnreadNotifications} +
+ {notificationManager.notifications.length} +
+ {/if} +
{#if shouldShowNotificationPanel} diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte index 0d05e2d6d7..e713eb7743 100644 --- a/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte @@ -1,12 +1,19 @@
{#each notificationManager.notifications as notification (notification.id)}
- markAsRead(id)} /> +
{/each}