feat: local album events notification (#22817)

* feat: local album events notification

* pr feedback

* show number of unread notification
This commit is contained in:
Alex
2025-10-14 10:15:51 -05:00
committed by GitHub
parent 4d41fa08ad
commit d778286777
10 changed files with 148 additions and 14 deletions

View File

@@ -26,6 +26,8 @@ class NotificationType {
static const jobFailed = NotificationType._(r'JobFailed'); static const jobFailed = NotificationType._(r'JobFailed');
static const backupFailed = NotificationType._(r'BackupFailed'); static const backupFailed = NotificationType._(r'BackupFailed');
static const systemMessage = NotificationType._(r'SystemMessage'); static const systemMessage = NotificationType._(r'SystemMessage');
static const albumInvite = NotificationType._(r'AlbumInvite');
static const albumUpdate = NotificationType._(r'AlbumUpdate');
static const custom = NotificationType._(r'Custom'); static const custom = NotificationType._(r'Custom');
/// List of all possible values in this [enum][NotificationType]. /// List of all possible values in this [enum][NotificationType].
@@ -33,6 +35,8 @@ class NotificationType {
jobFailed, jobFailed,
backupFailed, backupFailed,
systemMessage, systemMessage,
albumInvite,
albumUpdate,
custom, custom,
]; ];
@@ -75,6 +79,8 @@ class NotificationTypeTypeTransformer {
case r'JobFailed': return NotificationType.jobFailed; case r'JobFailed': return NotificationType.jobFailed;
case r'BackupFailed': return NotificationType.backupFailed; case r'BackupFailed': return NotificationType.backupFailed;
case r'SystemMessage': return NotificationType.systemMessage; case r'SystemMessage': return NotificationType.systemMessage;
case r'AlbumInvite': return NotificationType.albumInvite;
case r'AlbumUpdate': return NotificationType.albumUpdate;
case r'Custom': return NotificationType.custom; case r'Custom': return NotificationType.custom;
default: default:
if (!allowNull) { if (!allowNull) {

View File

@@ -12820,6 +12820,8 @@
"JobFailed", "JobFailed",
"BackupFailed", "BackupFailed",
"SystemMessage", "SystemMessage",
"AlbumInvite",
"AlbumUpdate",
"Custom" "Custom"
], ],
"type": "string" "type": "string"

View File

@@ -4650,6 +4650,8 @@ export enum NotificationType {
JobFailed = "JobFailed", JobFailed = "JobFailed",
BackupFailed = "BackupFailed", BackupFailed = "BackupFailed",
SystemMessage = "SystemMessage", SystemMessage = "SystemMessage",
AlbumInvite = "AlbumInvite",
AlbumUpdate = "AlbumUpdate",
Custom = "Custom" Custom = "Custom"
} }
export enum UserStatus { export enum UserStatus {

View File

@@ -722,6 +722,8 @@ export enum NotificationType {
JobFailed = 'JobFailed', JobFailed = 'JobFailed',
BackupFailed = 'BackupFailed', BackupFailed = 'BackupFailed',
SystemMessage = 'SystemMessage', SystemMessage = 'SystemMessage',
AlbumInvite = 'AlbumInvite',
AlbumUpdate = 'AlbumUpdate',
Custom = 'Custom', Custom = 'Custom',
} }

View File

@@ -7,6 +7,7 @@ import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types'; import { INotifyAlbumUpdateJob } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { notificationStub } from 'test/fixtures/notification.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; 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); 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); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
}); });
@@ -313,6 +316,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
@@ -334,6 +338,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
@@ -363,6 +368,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([ mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' }, { id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' },
@@ -394,6 +400,7 @@ describe(NotificationService.name, () => {
], ],
}); });
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]);
@@ -431,6 +438,7 @@ describe(NotificationService.name, () => {
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValueOnce(userStub.user1); mocks.user.get.mockResolvedValueOnce(userStub.user1);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
@@ -453,6 +461,7 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
@@ -475,6 +484,7 @@ describe(NotificationService.name, () => {
}, },
], ],
}); });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
@@ -489,6 +499,7 @@ describe(NotificationService.name, () => {
albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser],
}); });
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);

View File

@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { MapAlbumDto } from 'src/dtos/album.dto';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@@ -295,6 +296,8 @@ export class NotificationService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, album.owner.name);
const { emailNotifications } = getPreferences(recipient.metadata); const { emailNotifications } = getPreferences(recipient.metadata);
if (!emailNotifications.enabled || !emailNotifications.albumInvite) { if (!emailNotifications.enabled || !emailNotifications.albumInvite) {
@@ -344,6 +347,8 @@ export class NotificationService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumUpdate);
const attachment = await this.getAlbumThumbnailAttachment(album); const attachment = await this.getAlbumThumbnailAttachment(album);
const { server, templates } = await this.getConfig({ withCache: false }); const { server, templates } = await this.getConfig({ withCache: false });
@@ -431,4 +436,25 @@ export class NotificationService extends BaseService {
cid: 'album-thumbnail', 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));
}
} }

View File

@@ -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,
},
};

View File

@@ -19,6 +19,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { Button, IconButton } from '@immich/ui'; import { Button, IconButton } from '@immich/ui';
import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { mdiBellBadge, mdiBellOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ThemeButton from '../theme-button.svelte'; import ThemeButton from '../theme-button.svelte';
import UserAvatar from '../user-avatar.svelte'; import UserAvatar from '../user-avatar.svelte';
@@ -37,6 +38,14 @@
let shouldShowNotificationPanel = $state(false); let shouldShowNotificationPanel = $state(false);
let innerWidth: number = $state(0); let innerWidth: number = $state(0);
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 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);
}
});
</script> </script>
<svelte:window bind:innerWidth /> <svelte:window bind:innerWidth />
@@ -125,15 +134,25 @@
onEscape: () => (shouldShowNotificationPanel = false), onEscape: () => (shouldShowNotificationPanel = false),
}} }}
> >
<IconButton <div class="relative">
shape="round" <IconButton
color={hasUnreadNotifications ? 'primary' : 'secondary'} shape="round"
variant="ghost" color={hasUnreadNotifications ? 'primary' : 'secondary'}
size="medium" variant="ghost"
icon={hasUnreadNotifications ? mdiBellBadge : mdiBellOutline} size="medium"
onclick={() => (shouldShowNotificationPanel = !shouldShowNotificationPanel)} icon={hasUnreadNotifications ? mdiBellBadge : mdiBellOutline}
aria-label={$t('notifications')} onclick={() => (shouldShowNotificationPanel = !shouldShowNotificationPanel)}
/> aria-label={$t('notifications')}
/>
{#if hasUnreadNotifications}
<div
class="pointer-events-none absolute border top-0 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-light"
>
{notificationManager.notifications.length}
</div>
{/if}
</div>
{#if shouldShowNotificationPanel} {#if shouldShowNotificationPanel}
<NotificationPanel /> <NotificationPanel />

View File

@@ -1,12 +1,19 @@
<script lang="ts"> <script lang="ts">
import { NotificationLevel, NotificationType, type NotificationDto } from '@immich/sdk'; import { NotificationLevel, NotificationType, type NotificationDto } from '@immich/sdk';
import { IconButton, Stack, Text } from '@immich/ui'; import { IconButton, Stack, Text } from '@immich/ui';
import { mdiBackupRestore, mdiInformationOutline, mdiMessageBadgeOutline, mdiSync } from '@mdi/js'; import {
mdiBackupRestore,
mdiImageAlbum,
mdiImagePlus,
mdiInformationOutline,
mdiMessageBadgeOutline,
mdiSync,
} from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
interface Props { interface Props {
notification: NotificationDto; notification: NotificationDto;
onclick: (id: string) => void; onclick: (notification: NotificationDto) => void;
} }
let { notification, onclick }: Props = $props(); let { notification, onclick }: Props = $props();
@@ -62,6 +69,18 @@
case NotificationType.Custom: { case NotificationType.Custom: {
return mdiInformationOutline; return mdiInformationOutline;
} }
case NotificationType.AlbumInvite: {
return mdiImageAlbum;
}
case NotificationType.AlbumUpdate: {
return mdiImagePlus;
}
default: {
return mdiInformationOutline;
}
} }
}; };
@@ -83,7 +102,7 @@
<button <button
class="min-h-[80px] p-2 py-3 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 border-b border-gray-200 dark:border-immich-dark-gray w-full" class="min-h-[80px] p-2 py-3 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 border-b border-gray-200 dark:border-immich-dark-gray w-full"
type="button" type="button"
onclick={() => onclick(notification.id)} onclick={() => onclick(notification)}
title={notification.createdAt} title={notification.createdAt}
> >
<div class="grid grid-cols-[56px_1fr_32px] items-center gap-2"> <div class="grid grid-cols-[56px_1fr_32px] items-center gap-2">
@@ -99,7 +118,7 @@
</div> </div>
<Stack class="text-left" gap={1}> <Stack class="text-left" gap={1}>
<Text size="tiny" class="uppercase text-black dark:text-white font-semibold">{notification.title}</Text> <Text size="tiny" class="text-black dark:text-white font-semibold text-base">{notification.title}</Text>
{#if notification.description} {#if notification.description}
<Text class="overflow-hidden text-gray-600 dark:text-gray-300">{notification.description}</Text> <Text class="overflow-hidden text-gray-600 dark:text-gray-300">{notification.description}</Text>
{/if} {/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
import NotificationItem from '$lib/components/shared-components/navigation-bar/notification-item.svelte'; import NotificationItem from '$lib/components/shared-components/navigation-bar/notification-item.svelte';
import { import {
@@ -8,6 +9,7 @@
import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { NotificationType, type NotificationDto } from '@immich/sdk';
import { Button, Icon, Scrollable, Stack, Text } from '@immich/ui'; import { Button, Icon, Scrollable, Stack, Text } from '@immich/ui';
import { mdiBellOutline, mdiCheckAll } from '@mdi/js'; import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -32,6 +34,37 @@
handleError(error, $t('errors.failed_to_update_notification_status')); handleError(error, $t('errors.failed_to_update_notification_status'));
} }
}; };
const handleNotificationAction = async (notification: NotificationDto) => {
switch (notification.type) {
case NotificationType.AlbumInvite:
case NotificationType.AlbumUpdate: {
if (!notification.data) {
return;
}
if (typeof notification.data !== 'string') {
return;
}
const data = JSON.parse(notification.data);
if (data?.albumId) {
await goto(`/albums/${data.albumId}`);
}
break;
}
default: {
break;
}
}
};
const onclick = async (notification: NotificationDto) => {
await markAsRead(notification.id);
await handleNotificationAction(notification);
};
</script> </script>
<div <div
@@ -71,7 +104,7 @@
<Stack gap={0}> <Stack gap={0}>
{#each notificationManager.notifications as notification (notification.id)} {#each notificationManager.notifications as notification (notification.id)}
<div animate:flip={{ duration: 400 }}> <div animate:flip={{ duration: 400 }}>
<NotificationItem {notification} onclick={(id) => markAsRead(id)} /> <NotificationItem {notification} {onclick} />
</div> </div>
{/each} {/each}
</Stack> </Stack>