feat(web): enable websocket (#3765)

* send store event to page

* fix format

* add new asset to existing bucket

* format

* debouncing

* format

* load bucket

* feedback

* feat: listen to deletes and auto-subscribe on all asset grid pages

* feat: auto refresh on person thumbnail

* chore: skip upload event for now

* fix: person thumbnail event

* fix merge

* update handleAssetDeletion with websocket communication

* update info box on mount

* fix test

* fix test

* feat: event for trash asset

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Alex 2023-10-06 15:48:11 -05:00 committed by GitHub
parent 4dffae3f39
commit 36b21948bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 279 additions and 136 deletions

View file

@ -13,6 +13,7 @@
import type { AssetStore } from '$lib/stores/assets.store';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import type { Viewport } from '$lib/stores/assets.store';
import { flip } from 'svelte/animate';
export let assets: AssetResponseDto[];
export let bucketDate: string;
@ -176,6 +177,7 @@
<div
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
animate:flip={{ duration: 350 }}
>
<Thumbnail
{asset}

View file

@ -44,6 +44,7 @@
onMount(async () => {
showSkeleton = false;
document.addEventListener('keydown', onKeyboardPress);
assetStore.connect();
await assetStore.init(viewport);
});
@ -55,6 +56,8 @@
if ($showAssetViewer) {
$showAssetViewer = false;
}
assetStore.disconnect();
});
const handleKeyboardPress = (event: KeyboardEvent) => {

View file

@ -1,52 +1,40 @@
<script lang="ts">
import { browser } from '$app/environment';
import { locale } from '$lib/stores/preferences.store';
import { websocketStore } from '$lib/stores/websocket';
import { ServerInfoResponseDto, api } from '@api';
import { onDestroy, onMount } from 'svelte';
import Cloud from 'svelte-material-icons/Cloud.svelte';
import Dns from 'svelte-material-icons/Dns.svelte';
import LoadingSpinner from './loading-spinner.svelte';
import { api, ServerInfoResponseDto } from '@api';
import { asByteUnitString } from '../../utils/byte-units';
import { locale } from '$lib/stores/preferences.store';
import LoadingSpinner from './loading-spinner.svelte';
const { serverVersion, connected } = websocketStore;
let isServerOk = true;
let serverVersion = '';
let serverInfo: ServerInfoResponseDto;
let pingServerInterval: NodeJS.Timer;
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: usedPercentage = Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100);
onMount(async () => {
try {
const { data: version } = await api.serverInfoApi.getServerVersion();
serverVersion = `v${version.major}.${version.minor}.${version.patch}`;
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
serverInfo = serverInfoRes;
getStorageUsagePercentage();
} catch (e) {
console.log('Error [StatusBox] [onMount]');
isServerOk = false;
}
pingServerInterval = setInterval(async () => {
try {
const { data: pingReponse } = await api.serverInfoApi.pingServer();
if (pingReponse.res === 'pong') isServerOk = true;
else isServerOk = false;
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
serverInfo = serverInfoRes;
} catch (e) {
console.log('Error [StatusBox] [pingServerInterval]', e);
isServerOk = false;
}
}, 10000);
await refresh();
});
onDestroy(() => clearInterval(pingServerInterval));
const getStorageUsagePercentage = () => {
return Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100);
const refresh = async () => {
try {
const { data } = await api.serverInfoApi.getServerInfo();
serverInfo = data;
} catch (e) {
console.log('Error [StatusBox] [onMount]');
}
};
let interval: number;
if (browser) {
interval = window.setInterval(() => refresh(), 10_000);
}
onDestroy(() => clearInterval(interval));
</script>
<div class="dark:text-immich-dark-fg">
@ -61,7 +49,7 @@
<!-- style={`width: ${$downloadAssets[fileName]}%`} -->
<div
class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary"
style="width: {getStorageUsagePercentage()}%"
style="width: {usedPercentage}%"
/>
</div>
<p class="text-xs">
@ -88,7 +76,7 @@
<div class="mt-2 flex justify-between justify-items-center">
<p>Status</p>
{#if isServerOk}
{#if $connected}
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p>
{:else}
<p class="font-medium text-red-500">Offline</p>
@ -97,20 +85,18 @@
<div class="mt-2 flex justify-between justify-items-center">
<p>Version</p>
<a
href="https://github.com/immich-app/immich/releases"
class="font-medium text-immich-primary dark:text-immich-dark-primary"
target="_blank"
>
{serverVersion}
</a>
{#if $connected && version}
<a
href="https://github.com/immich-app/immich/releases"
class="font-medium text-immich-primary dark:text-immich-dark-primary"
target="_blank"
>
{version}
</a>
{:else}
<p class="font-medium text-red-500">Unknown</p>
{/if}
</div>
</div>
</div>
<!-- <div>
<hr class="ml-5 my-4" />
</div>
<button class="text-xs ml-5 underline hover:cursor-pointer text-immich-primary" on:click={() => goto('/changelog')}
>Changelog</button
> -->
</div>

View file

@ -36,15 +36,12 @@
in:fade={{ duration: 250 }}
out:fade={{ duration: 250 }}
on:outroend={() => {
const errorInfo =
$errorCounter > 0
? `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}`
: 'Upload success';
const type = $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info;
notificationController.show({
message: `${errorInfo}, refresh the page to see new upload assets`,
type,
message:
($errorCounter > 0
? `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}`
: 'Upload success') + ', refresh the page to see new upload assets.',
type: $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info,
});
if ($duplicateCounter > 0) {

View file

@ -1,6 +1,9 @@
import { api, AssetApiGetTimeBucketsRequest, AssetResponseDto } from '@api';
import { writable } from 'svelte/store';
import { throttle } from 'lodash-es';
import { DateTime } from 'luxon';
import { Unsubscriber, writable } from 'svelte/store';
import { handleError } from '../utils/handle-error';
import { websocketStore } from './websocket';
export enum BucketPosition {
Above = 'above',
@ -34,11 +37,33 @@ export class AssetBucket {
position!: BucketPosition;
}
const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
option === undefined ? false : option !== value;
const THUMBNAIL_HEIGHT = 235;
interface AddAsset {
type: 'add';
value: AssetResponseDto;
}
interface DeleteAsset {
type: 'delete';
value: string;
}
interface TrashAsset {
type: 'trash';
value: string;
}
type PendingChange = AddAsset | DeleteAsset | TrashAsset;
export class AssetStore {
private store$ = writable(this);
private assetToBucket: Record<string, AssetLookup> = {};
private pendingChanges: PendingChange[] = [];
private unsubscribers: Unsubscriber[] = [];
initialized = false;
timelineHeight = 0;
@ -52,6 +77,63 @@ export class AssetStore {
subscribe = this.store$.subscribe;
connect() {
this.unsubscribers.push(
websocketStore.onUploadSuccess.subscribe((value) => {
if (value) {
this.pendingChanges.push({ type: 'add', value });
this.processPendingChanges();
}
}),
websocketStore.onAssetTrash.subscribe((ids) => {
console.log('onAssetTrash', ids);
if (ids) {
for (const id of ids) {
this.pendingChanges.push({ type: 'trash', value: id });
}
this.processPendingChanges();
}
}),
websocketStore.onAssetDelete.subscribe((value) => {
if (value) {
this.pendingChanges.push({ type: 'delete', value });
this.processPendingChanges();
}
}),
);
}
disconnect() {
for (const unsubscribe of this.unsubscribers) {
unsubscribe();
}
}
processPendingChanges = throttle(() => {
for (const { type, value } of this.pendingChanges) {
switch (type) {
case 'add':
this.addAsset(value);
break;
case 'trash':
if (!this.options.isTrashed) {
this.removeAsset(value);
}
break;
case 'delete':
this.removeAsset(value);
break;
}
}
this.pendingChanges = [];
this.emit(true);
}, 10_000);
async init(viewport: Viewport) {
this.initialized = false;
this.timelineHeight = 0;
@ -168,6 +250,46 @@ export class AssetStore {
return scrollTimeline ? delta : 0;
}
private addAsset(asset: AssetResponseDto): void {
if (
this.assetToBucket[asset.id] ||
this.options.userId ||
this.options.personId ||
this.options.albumId ||
isMismatched(this.options.isArchived, asset.isArchived) ||
isMismatched(this.options.isFavorite, asset.isFavorite)
) {
return;
}
const timeBucket = DateTime.fromISO(asset.fileCreatedAt).toUTC().startOf('month').toString();
let bucket = this.getBucketByDate(timeBucket);
if (!bucket) {
bucket = {
bucketDate: timeBucket,
bucketHeight: THUMBNAIL_HEIGHT,
assets: [],
cancelToken: null,
position: BucketPosition.Unknown,
};
this.buckets.push(bucket);
this.buckets = this.buckets.sort((a, b) => {
const aDate = DateTime.fromISO(a.bucketDate).toUTC();
const bDate = DateTime.fromISO(b.bucketDate).toUTC();
return bDate.diff(aDate).milliseconds;
});
}
bucket.assets.push(asset);
bucket.assets.sort((a, b) => {
const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC();
const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
return bDate.diff(aDate).milliseconds;
});
}
getBucketByDate(bucketDate: string): AssetBucket | null {
return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
}

View file

@ -1,10 +1,19 @@
import { io, Socket } from 'socket.io-client';
import type { AssetResponseDto, ServerVersionResponseDto } from '@api';
import { io } from 'socket.io-client';
import { writable } from 'svelte/store';
let websocket: Socket;
export const websocketStore = {
onUploadSuccess: writable<AssetResponseDto>(),
onAssetDelete: writable<string>(),
onAssetTrash: writable<string[]>(),
onPersonThumbnail: writable<string>(),
serverVersion: writable<ServerVersionResponseDto>(),
connected: writable<boolean>(false),
};
export const openWebsocketConnection = () => {
try {
websocket = io('', {
const websocket = io('', {
path: '/api/socket.io',
transports: ['polling'],
reconnection: true,
@ -12,21 +21,18 @@ export const openWebsocketConnection = () => {
autoConnect: true,
});
listenToEvent(websocket);
websocket
.on('connect', () => websocketStore.connected.set(true))
.on('disconnect', () => websocketStore.connected.set(false))
// .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(JSON.parse(data) as AssetResponseDto))
.on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(JSON.parse(data) as string))
.on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(JSON.parse(data) as string[]))
.on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(JSON.parse(data) as string))
.on('on_server_version', (data) => websocketStore.serverVersion.set(JSON.parse(data) as ServerVersionResponseDto))
.on('error', (e) => console.log('Websocket Error', e));
return () => websocket?.close();
} catch (e) {
console.log('Cannot connect to websocket ', e);
}
};
const listenToEvent = (socket: Socket) => {
//TODO: if we are not using this, we should probably remove it?
socket.on('on_upload_success', () => undefined);
socket.on('error', (e) => {
console.log('Websocket Error', e);
});
};
export const closeWebsocketConnection = () => {
websocket?.close();
};