mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	feat: local album events notification (#22817)
* feat: local album events notification * pr feedback * show number of unread notification
This commit is contained in:
		
							parent
							
								
									4d41fa08ad
								
							
						
					
					
						commit
						d778286777
					
				
							
								
								
									
										6
									
								
								mobile/openapi/lib/model/notification_type.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/lib/model/notification_type.dart
									
									
									
										generated
									
									
									
								
							@ -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) {
 | 
				
			||||||
 | 
				
			|||||||
@ -12820,6 +12820,8 @@
 | 
				
			|||||||
          "JobFailed",
 | 
					          "JobFailed",
 | 
				
			||||||
          "BackupFailed",
 | 
					          "BackupFailed",
 | 
				
			||||||
          "SystemMessage",
 | 
					          "SystemMessage",
 | 
				
			||||||
 | 
					          "AlbumInvite",
 | 
				
			||||||
 | 
					          "AlbumUpdate",
 | 
				
			||||||
          "Custom"
 | 
					          "Custom"
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        "type": "string"
 | 
					        "type": "string"
 | 
				
			||||||
 | 
				
			|||||||
@ -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 {
 | 
				
			||||||
 | 
				
			|||||||
@ -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',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -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([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -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));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								server/test/fixtures/notification.stub.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								server/test/fixtures/notification.stub.ts
									
									
									
									
										vendored
									
									
										Normal 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,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -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,6 +134,7 @@
 | 
				
			|||||||
            onEscape: () => (shouldShowNotificationPanel = false),
 | 
					            onEscape: () => (shouldShowNotificationPanel = false),
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
 | 
					          <div class="relative">
 | 
				
			||||||
            <IconButton
 | 
					            <IconButton
 | 
				
			||||||
              shape="round"
 | 
					              shape="round"
 | 
				
			||||||
              color={hasUnreadNotifications ? 'primary' : 'secondary'}
 | 
					              color={hasUnreadNotifications ? 'primary' : 'secondary'}
 | 
				
			||||||
@ -135,6 +145,15 @@
 | 
				
			|||||||
              aria-label={$t('notifications')}
 | 
					              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 />
 | 
				
			||||||
          {/if}
 | 
					          {/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -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}
 | 
				
			||||||
 | 
				
			|||||||
@ -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>
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user