diff --git a/web/package-lock.json b/web/package-lock.json index f521c97bfeef3..0177175c30237 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,6 +29,7 @@ "devDependencies": { "@faker-js/faker": "^8.0.0", "@floating-ui/dom": "^1.5.1", + "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/kit": "^2.0.6", "@sveltejs/vite-plugin-svelte": "^3.0.0", diff --git a/web/package.json b/web/package.json index d41b8b9ce7496..35eac08ddac30 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@faker-js/faker": "^8.0.0", "@floating-ui/dom": "^1.5.1", + "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/kit": "^2.0.6", "@sveltejs/vite-plugin-svelte": "^3.0.0", diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 252119a0aa938..97d3e2dfea701 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -6,7 +6,7 @@ import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; - import { websocketStore } from '$lib/stores/websocket'; + import { websocketEvents } from '$lib/stores/websocket'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl, isSharedLink } from '$lib/utils'; import { getAssetFilename } from '$lib/utils/asset-utils'; import { autoGrowHeight } from '$lib/utils/autogrow'; @@ -30,7 +30,7 @@ mdiPencil, } from '@mdi/js'; import { DateTime } from 'luxon'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { createEventDispatcher, onMount } from 'svelte'; import { slide } from 'svelte/transition'; import { asByteUnitString } from '../../utils/byte-units'; import { handleError } from '../../utils/handle-error'; @@ -91,14 +91,12 @@ $: people = asset.people || []; $: showingHiddenPeople = false; - const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => { - if (assetUpdate && assetUpdate.id === asset.id) { - asset = assetUpdate; - } - }); - - onDestroy(() => { - unsubscribe(); + onMount(() => { + return websocketEvents.on('on_asset_update', (assetUpdate) => { + if (assetUpdate.id === asset.id) { + asset = assetUpdate; + } + }); }); const dispatch = createEventDispatcher<{ diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index a34ee322f6b39..91a9587998fb1 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -2,7 +2,7 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { boundingBoxesArray } from '$lib/stores/people.store'; - import { websocketStore } from '$lib/stores/websocket'; + import { websocketEvents } from '$lib/stores/websocket'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { getPersonNameWithHiddenValue } from '$lib/utils/person'; @@ -49,32 +49,12 @@ let loaderLoadingDoneTimeout: NodeJS.Timeout; let automaticRefreshTimeout: NodeJS.Timeout; - const { onPersonThumbnail } = websocketStore; const dispatch = createEventDispatcher<{ close: void; refresh: void; }>(); - // Reset value - $onPersonThumbnail = ''; - - $: { - if ($onPersonThumbnail) { - numberOfAssetFaceGenerated.push($onPersonThumbnail); - if ( - isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) && - loaderLoadingDoneTimeout && - automaticRefreshTimeout && - selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length - ) { - clearTimeout(loaderLoadingDoneTimeout); - clearTimeout(automaticRefreshTimeout); - dispatch('refresh'); - } - } - } - - onMount(async () => { + async function loadPeople() { const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); try { const { people } = await getAllPeople({ withHidden: true }); @@ -88,6 +68,25 @@ clearTimeout(timeout); } isShowLoadingPeople = false; + } + + const onPersonThumbnail = (personId: string) => { + numberOfAssetFaceGenerated.push(personId); + if ( + isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) && + loaderLoadingDoneTimeout && + automaticRefreshTimeout && + selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length + ) { + clearTimeout(loaderLoadingDoneTimeout); + clearTimeout(automaticRefreshTimeout); + dispatch('refresh'); + } + }; + + onMount(() => { + loadPeople(); + return websocketEvents.on('on_person_thumbnail', onPersonThumbnail); }); const isEqual = (a: string[], b: string[]): boolean => { diff --git a/web/src/lib/components/shared-components/update-panel.svelte b/web/src/lib/components/shared-components/update-panel.svelte index 13cbeecd7edfe..c566bf0e79a4c 100644 --- a/web/src/lib/components/shared-components/update-panel.svelte +++ b/web/src/lib/components/shared-components/update-panel.svelte @@ -1,15 +1,18 @@ diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte index 93ba1d7a4b67e..1c58a37286d0b 100644 --- a/web/src/lib/components/shared-components/version-announcement-box.svelte +++ b/web/src/lib/components/shared-components/version-announcement-box.svelte @@ -6,13 +6,13 @@ let showModal = false; - const { onRelease } = websocketStore; + const { release } = websocketStore; const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; - $: releaseVersion = $onRelease && semverToName($onRelease.releaseVersion); - $: serverVersion = $onRelease && semverToName($onRelease.serverVersion); - $: $onRelease?.isAvailable && handleRelease(); + $: releaseVersion = $release && semverToName($release.releaseVersion); + $: serverVersion = $release && semverToName($release.serverVersion); + $: $release?.isAvailable && handleRelease(); const onAcknowledge = () => { localStorage.setItem('appVersion', releaseVersion); diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 3000a64b06592..f6f22197030bc 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -4,7 +4,7 @@ import { throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; -import { websocketStore } from './websocket'; +import { websocketEvents } from './websocket'; export enum BucketPosition { Above = 'above', @@ -96,22 +96,14 @@ export class AssetStore { connect() { this.unsubscribers.push( - websocketStore.onUploadSuccess.subscribe((value) => { - if (value) { - this.addPendingChanges({ type: 'add', value }); - } + websocketEvents.on('on_upload_success', (asset) => { + this.addPendingChanges({ type: 'add', value: asset }); }), - - websocketStore.onAssetTrash.subscribe((ids) => { - if (ids) { - this.addPendingChanges(...ids.map((id) => ({ type: 'trash', value: id }) as PendingChange)); - } + websocketEvents.on('on_asset_trash', (ids) => { + this.addPendingChanges(...ids.map((id): TrashAsset => ({ type: 'trash', value: id }))); }), - - websocketStore.onAssetDelete.subscribe((value) => { - if (value) { - this.addPendingChanges({ type: 'delete', value }); - } + websocketEvents.on('on_asset_delete', (id: string) => { + this.addPendingChanges({ type: 'delete', value: id }); }), ); } diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 71ba4354f02e9..33748f35fe5bc 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,7 +1,7 @@ +import { createEventEmitter } from '$lib/utils/eventemitter'; import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; -import { loadConfig } from './server-config.store'; import { user } from './user.store'; export interface ReleaseEvent { @@ -10,58 +10,54 @@ export interface ReleaseEvent { serverVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto; } +export interface Events { + on_upload_success: (asset: AssetResponseDto) => void; + on_asset_delete: (assetId: string) => void; + on_asset_trash: (assetIds: string[]) => void; + on_asset_update: (asset: AssetResponseDto) => void; + on_asset_hidden: (assetId: string) => void; + on_asset_restore: (assetIds: string[]) => void; + on_person_thumbnail: (personId: string) => void; + on_server_version: (serverVersion: ServerVersionResponseDto) => void; + on_config_update: () => void; + on_new_release: (newRelase: ReleaseEvent) => void; +} + +const websocket: Socket = io('', { + path: '/api/socket.io', + transports: ['websocket'], + reconnection: true, + forceNew: true, + autoConnect: false, +}); export const websocketStore = { - onUploadSuccess: writable(), - onAssetDelete: writable(), - onAssetTrash: writable(), - onAssetUpdate: writable(), - onPersonThumbnail: writable(), - serverVersion: writable(), connected: writable(false), - onRelease: writable(), + serverVersion: writable(), + release: writable(), }; -let websocket: Socket | null = null; +export const websocketEvents = createEventEmitter(websocket); + +websocket + .on('connect', () => websocketStore.connected.set(true)) + .on('disconnect', () => websocketStore.connected.set(false)) + .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) + .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) + .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = async () => { try { - if (websocket) { - return; - } - if (!get(user)) { return; } - websocket = io('', { - path: '/api/socket.io', - transports: ['websocket'], - reconnection: true, - forceNew: true, - autoConnect: true, - }); - - websocket - .on('connect', () => websocketStore.connected.set(true)) - .on('disconnect', () => websocketStore.connected.set(false)) - // .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(data)) - .on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(data)) - .on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(data)) - .on('on_asset_update', (data) => websocketStore.onAssetUpdate.set(data)) - .on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(data)) - .on('on_server_version', (data) => websocketStore.serverVersion.set(data)) - .on('on_config_update', () => loadConfig()) - .on('on_new_release', (data) => websocketStore.onRelease.set(data)) - .on('error', (e) => console.log('Websocket Error', e)); + websocket.connect(); } catch (error) { console.log('Cannot connect to websocket', error); } }; export const closeWebsocketConnection = () => { - if (websocket) { - websocket.close(); - } - websocket = null; + websocket.disconnect(); }; diff --git a/web/src/lib/utils/eventemitter.ts b/web/src/lib/utils/eventemitter.ts new file mode 100644 index 0000000000000..35d8eecf8738e --- /dev/null +++ b/web/src/lib/utils/eventemitter.ts @@ -0,0 +1,42 @@ +import type { + DefaultEventsMap, + EventsMap, + ReservedOrUserEventNames, + ReservedOrUserListener, +} from '@socket.io/component-emitter'; +import type { Socket } from 'socket.io-client'; + +export function createEventEmitter< + ListenEvents extends EventsMap = DefaultEventsMap, + EmitEvents extends EventsMap = ListenEvents, + ReservedEvents extends EventsMap = NonNullable, +>(socket: Socket) { + function on>( + ev: Ev, + listener: ReservedOrUserListener, + ) { + socket.on(ev, listener); + return () => { + socket.off(ev, listener); + }; + } + + function once>( + ev: Ev, + listener: ReservedOrUserListener, + ) { + socket.once(ev, listener); + return () => { + socket.off(ev, listener); + }; + } + + function off>( + ev: Ev, + listener: ReservedOrUserListener, + ) { + socket.off(ev, listener); + } + + return { on, once, off }; +} diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index babaca1069b21..64381677f0dd7 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -30,7 +30,7 @@ import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; - import { websocketStore } from '$lib/stores/websocket'; + import { websocketEvents } from '$lib/stores/websocket'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { clickOutside } from '$lib/utils/click-outside'; import { handleError } from '$lib/utils/handle-error'; @@ -68,7 +68,6 @@ }); const assetInteractionStore = createAssetInteractionStore(); const { selectedAssets, isMultiSelectState } = assetInteractionStore; - const { onPersonThumbnail } = websocketStore; let viewMode: ViewMode = ViewMode.VIEW_ASSETS; let isEditingName = false; @@ -119,8 +118,6 @@ $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); - $: $onPersonThumbnail === data.person.id && - (thumbnailData = getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`); $: { if (people) { @@ -138,6 +135,12 @@ if (action == 'merge') { viewMode = ViewMode.MERGE_PEOPLE; } + + return websocketEvents.on('on_person_thumbnail', (personId: string) => { + if (data.person.id === personId) { + thumbnailData = getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`; + } + }); }); const handleKeyboardPress = (event: KeyboardEvent) => {