From d778286777b9d29c7fe30f085ff450ea7fd177ce Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 14 Oct 2025 10:15:51 -0500 Subject: [PATCH] feat: local album events notification (#22817) * feat: local album events notification * pr feedback * show number of unread notification --- .../openapi/lib/model/notification_type.dart | Bin 3023 -> 3319 bytes open-api/immich-openapi-specs.json | 2 + open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/enum.ts | 2 + .../src/services/notification.service.spec.ts | 11 ++++++ server/src/services/notification.service.ts | 26 ++++++++++++ server/test/fixtures/notification.stub.ts | 14 +++++++ .../navigation-bar/navigation-bar.svelte | 37 +++++++++++++----- .../navigation-bar/notification-item.svelte | 27 +++++++++++-- .../navigation-bar/notification-panel.svelte | 35 ++++++++++++++++- 10 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 server/test/fixtures/notification.stub.ts diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart index 436d2d190fd1e95ab4a6b005b5678992c7b7ff2e..b5885aa4414631fb4695ebddb7217ff50a466c1a 100644 GIT binary patch delta 248 zcmX>v{#|mzf2R7xoTSoR&%CnCl2ip-1;6}~%(Tqp#FEVXypYO*RK0kOB6UZE5_L^$ zE(L|+5}<-)h2;Fa;t~b00igvci3ALQC_y)1@*k!v(nxO50UED>Waj3R%tzR`kz6pD ipG#UDNdw3|5RjZ$oT>nIEyOaAS}uh9Hdk|nFaiMn`d2yt delta 21 dcmew^d0u?Of2PTe%ojJ8u { }, ], }); + 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 91a043d40..5d192523b 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 000000000..b5a436a62 --- /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 e4446cf14..51488381f 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 0d05e2d6d..e713eb774 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}