mirror of
https://github.com/immich-app/immich.git
synced 2026-01-15 14:33:16 -08:00
Compare commits
2 Commits
fix/drop-u
...
push-lmxsu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3305daa4a | ||
|
|
a476c025c9 |
@@ -23,7 +23,7 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import { preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { prepareImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetJobName,
|
||||
@@ -135,7 +135,7 @@
|
||||
|
||||
untrack(() => {
|
||||
if (stack && stack?.assets.length > 1) {
|
||||
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
|
||||
void prepareImageUrl(getAssetUrl({ asset: stack.assets[1] }));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -385,8 +385,8 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
preloadManager.preload(cursor.nextAsset);
|
||||
preloadManager.preload(cursor.previousAsset);
|
||||
void preloadManager.preload(cursor.nextAsset);
|
||||
void preloadManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { cancelImageUrl, prepareImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
class PreloadManager {
|
||||
preload(asset: AssetResponseDto | undefined) {
|
||||
if (globalThis.isSecureContext) {
|
||||
preloadImageUrl(getAssetUrl({ asset }));
|
||||
async preload(asset: AssetResponseDto | undefined) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
if (!asset || asset.type !== AssetTypeEnum.Image) {
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
const url = getAssetUrl({ asset });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the URL with the service worker for cancellation tracking
|
||||
await prepareImageUrl(url);
|
||||
|
||||
// Create an img element to trigger browser fetch (kept in memory, not added to DOM)
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,15 @@ import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, md
|
||||
import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
|
||||
export const ImageKinds = {
|
||||
thumbnail: true,
|
||||
preview: true,
|
||||
fullsize: true,
|
||||
original: true,
|
||||
} as const;
|
||||
|
||||
export type ImageKind = keyof typeof ImageKinds;
|
||||
|
||||
interface DownloadRequestOptions<T = unknown> {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
url: string;
|
||||
@@ -195,6 +204,23 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
|
||||
|
||||
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
|
||||
|
||||
export const getAssetUrlForKind = (asset: AssetResponseDto, kind: ImageKind) => {
|
||||
switch (kind) {
|
||||
case 'preview': {
|
||||
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey: asset.thumbhash });
|
||||
}
|
||||
case 'thumbnail': {
|
||||
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash });
|
||||
}
|
||||
case 'fullsize': {
|
||||
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash });
|
||||
}
|
||||
case 'original': {
|
||||
return getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getAssetUrl = ({
|
||||
asset,
|
||||
sharedLink,
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
import { ServiceWorkerMessenger } from './sw-messenger';
|
||||
|
||||
const messenger = new ServiceWorkerMessenger('immich');
|
||||
|
||||
let isServiceWorkerEnabled = true;
|
||||
|
||||
messenger.onAckTimeout(() => {
|
||||
if (!isServiceWorkerEnabled) {
|
||||
return;
|
||||
}
|
||||
console.error('[ServiceWorker] No communication detected. Auto-disabled service worker.');
|
||||
isServiceWorkerEnabled = false;
|
||||
});
|
||||
|
||||
const isValidSwContext = (url: string | undefined | null): url is string => {
|
||||
return globalThis.isSecureContext && isServiceWorkerEnabled && !!url;
|
||||
};
|
||||
|
||||
export function cancelImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
if (!isValidSwContext(url)) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
void messenger.send('cancel', { url });
|
||||
}
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
|
||||
export async function prepareImageUrl(url: string | undefined | null) {
|
||||
if (!isValidSwContext(url)) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
await messenger.send('prepare', { url });
|
||||
}
|
||||
|
||||
143
web/src/lib/utils/sw-messenger.ts
Normal file
143
web/src/lib/utils/sw-messenger.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Low-level protocol for communicating with the service worker via BroadcastChannel.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Main thread sends request: { type: string, requestId: string, ...data }
|
||||
* 2. SW sends ack: { type: 'ack', requestId: string }
|
||||
* 3. SW sends response (optional): { type: 'response', requestId: string, result?: any, error?: string }
|
||||
*/
|
||||
|
||||
interface PendingRequest {
|
||||
resolveAck: () => void;
|
||||
resolveResponse?: (result: unknown) => void;
|
||||
rejectResponse?: (error: Error) => void;
|
||||
ackTimeout: ReturnType<typeof setTimeout>;
|
||||
ackReceived: boolean;
|
||||
}
|
||||
|
||||
export class ServiceWorkerMessenger {
|
||||
readonly #broadcast: BroadcastChannel;
|
||||
readonly #pendingRequests = new Map<string, PendingRequest>();
|
||||
readonly #ackTimeoutMs: number;
|
||||
#requestCounter = 0;
|
||||
#onTimeout?: (type: string, data: Record<string, unknown>) => void;
|
||||
|
||||
constructor(channelName: string, ackTimeoutMs = 5000) {
|
||||
this.#broadcast = new BroadcastChannel(channelName);
|
||||
this.#ackTimeoutMs = ackTimeoutMs;
|
||||
|
||||
this.#broadcast.addEventListener('message', (event) => {
|
||||
this.#handleMessage(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
#handleMessage(data: unknown) {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data as { requestId?: string; type?: string; error?: string; result?: unknown };
|
||||
const requestId = message.requestId;
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.#pendingRequests.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ack') {
|
||||
pending.ackReceived = true;
|
||||
clearTimeout(pending.ackTimeout);
|
||||
pending.resolveAck();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'response') {
|
||||
clearTimeout(pending.ackTimeout);
|
||||
this.#pendingRequests.delete(requestId);
|
||||
|
||||
if (message.error) {
|
||||
pending.rejectResponse?.(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
|
||||
pending.resolveResponse?.(message.result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback to be invoked when an ack timeout occurs.
|
||||
* This can be used to detect and disable faulty service workers.
|
||||
*/
|
||||
onAckTimeout(callback: (type: string, data: Record<string, unknown>) => void): void {
|
||||
this.#onTimeout = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the service worker.
|
||||
* - send(): waits for ack, resolves when acknowledged
|
||||
* - request(): waits for response, throws on error/timeout
|
||||
*/
|
||||
#sendInternal<T>(type: string, data: Record<string, unknown>, waitForResponse: boolean): Promise<T> {
|
||||
const requestId = `${type}-${++this.#requestCounter}-${Date.now()}`;
|
||||
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
const ackTimeout = setTimeout(() => {
|
||||
const pending = this.#pendingRequests.get(requestId);
|
||||
if (pending && !pending.ackReceived) {
|
||||
this.#pendingRequests.delete(requestId);
|
||||
console.warn(`[ServiceWorker] ${type} request not acknowledged:`, data);
|
||||
this.#onTimeout?.(type, data);
|
||||
// Only reject if we're waiting for a response
|
||||
if (waitForResponse) {
|
||||
reject(new Error(`Service worker did not acknowledge ${type} request`));
|
||||
} else {
|
||||
pending.resolveAck();
|
||||
}
|
||||
}
|
||||
}, this.#ackTimeoutMs);
|
||||
|
||||
this.#pendingRequests.set(requestId, {
|
||||
resolveAck: waitForResponse ? () => {} : () => resolve(undefined as T),
|
||||
resolveResponse: waitForResponse ? (result: unknown) => resolve(result as T) : undefined,
|
||||
rejectResponse: waitForResponse ? reject : undefined,
|
||||
ackTimeout,
|
||||
ackReceived: false,
|
||||
});
|
||||
|
||||
this.#broadcast.postMessage({
|
||||
type,
|
||||
requestId,
|
||||
...data,
|
||||
});
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-way message to the service worker.
|
||||
* Returns a promise that resolves after the service worker acknowledges receipt.
|
||||
* Rejects if no ack is received within the timeout period.
|
||||
*/
|
||||
send(type: string, data: Record<string, unknown>): Promise<void> {
|
||||
return this.#sendInternal<void>(type, data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and wait for ack + response.
|
||||
* Returns a promise that resolves with the response data or rejects on error/timeout.
|
||||
*/
|
||||
request<T = void>(type: string, data: Record<string, unknown>): Promise<T> {
|
||||
return this.#sendInternal<T>(type, data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the broadcast channel
|
||||
*/
|
||||
close(): void {
|
||||
this.#broadcast.close();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,53 @@
|
||||
import { handleCancel, handlePreload } from './request';
|
||||
import { handleCancel, handlePrepare } from './request';
|
||||
|
||||
/**
|
||||
* Send acknowledgment for a request
|
||||
*/
|
||||
function sendAck(broadcast: BroadcastChannel, requestId: string) {
|
||||
broadcast.postMessage({
|
||||
type: 'ack',
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 'prepare' request: prepare SW to track this request for cancelation
|
||||
*/
|
||||
const handlePrepareRequest = (broadcast: BroadcastChannel, url: URL, requestId: string) => {
|
||||
sendAck(broadcast, requestId);
|
||||
handlePrepare(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle 'cancel' request: cancel a pending request
|
||||
*/
|
||||
const handleCancelRequest = (broadcast: BroadcastChannel, url: URL, requestId: string) => {
|
||||
sendAck(broadcast, requestId);
|
||||
handleCancel(url);
|
||||
};
|
||||
|
||||
export const installBroadcastChannelListener = () => {
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
broadcast.onmessage = (event) => {
|
||||
if (!event.data) {
|
||||
if (!event.data?.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.data.url, event.origin);
|
||||
const requestId = event.data.requestId;
|
||||
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'preload': {
|
||||
handlePreload(url);
|
||||
case 'prepare': {
|
||||
handlePrepareRequest(broadcast, url, requestId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
handleCancel(url);
|
||||
handleCancelRequest(broadcast, url, requestId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { version } from '$service-worker';
|
||||
|
||||
const CACHE = `cache-${version}`;
|
||||
|
||||
let _cache: Cache | undefined;
|
||||
const getCache = async () => {
|
||||
if (_cache) {
|
||||
return _cache;
|
||||
}
|
||||
_cache = await caches.open(CACHE);
|
||||
return _cache;
|
||||
};
|
||||
|
||||
export const get = async (key: string) => {
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
return cache.match(key);
|
||||
};
|
||||
|
||||
export const put = async (key: string, response: Response) => {
|
||||
if (response.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.put(key, response.clone());
|
||||
};
|
||||
|
||||
export const prune = async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,7 @@
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||
import { prune } from './cache';
|
||||
import { handleRequest } from './request';
|
||||
import { handleFetch as handleAssetFetch } from './request';
|
||||
|
||||
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||
|
||||
@@ -12,7 +11,6 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const handleActivate = (event: ExtendableEvent) => {
|
||||
event.waitUntil(sw.clients.claim());
|
||||
event.waitUntil(prune());
|
||||
};
|
||||
|
||||
const handleInstall = (event: ExtendableEvent) => {
|
||||
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
// Cache requests for thumbnails
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
|
||||
event.respondWith(handleRequest(event.request));
|
||||
event.respondWith(handleAssetFetch(event.request));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,73 +1,124 @@
|
||||
import { get, put } from './cache';
|
||||
type PendingRequest = {
|
||||
controller: AbortController;
|
||||
promise: Promise<Response>;
|
||||
canceled: boolean;
|
||||
canceledAt?: number; // Timestamp when cancellation occurred
|
||||
fetchStartedAt?: number; // Timestamp when fetch body)
|
||||
};
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
|
||||
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
|
||||
|
||||
const assertResponse = (response: Response) => {
|
||||
if (!(response instanceof Response)) {
|
||||
throw new TypeError('Fetch did not return a valid Response object');
|
||||
const CANCELED_MESSAGE = 'Canceled - this is normal';
|
||||
|
||||
/**
|
||||
* Clean up old requests after a timeout
|
||||
*/
|
||||
const CANCELATION_EXPIRED_TIMEOUT_MS = 60_000;
|
||||
const FETCH_EXPIRED_TIMEOUT_MS = 60_000;
|
||||
|
||||
const cleanupOldRequests = () => {
|
||||
const now = Date.now();
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
for (const [key, request] of pendingRequests.entries()) {
|
||||
if (request.canceled && request.canceledAt) {
|
||||
const age = now - request.canceledAt;
|
||||
if (age > CANCELATION_EXPIRED_TIMEOUT_MS) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean up completed requests after 5s (allows time for potential cancellations)
|
||||
if (request.fetchStartedAt) {
|
||||
const age = now - request.fetchStartedAt;
|
||||
if (age > FETCH_EXPIRED_TIMEOUT_MS) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToDelete) {
|
||||
pendingRequests.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
}
|
||||
|
||||
if (isRequest(request)) {
|
||||
return request.url;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid request: ${request}`);
|
||||
/**
|
||||
* Get existing request and cleanup old requests
|
||||
*/
|
||||
const getExisting = (requestKey: string): PendingRequest | undefined => {
|
||||
cleanupOldRequests();
|
||||
return pendingRequests.get(requestKey);
|
||||
};
|
||||
|
||||
export const handlePreload = async (request: URL | Request) => {
|
||||
try {
|
||||
return await handleRequest(request);
|
||||
} catch (error) {
|
||||
console.error(`Preload failed: ${error}`);
|
||||
// Mark this URL as prepared - actual fetch will happen when handleFetch is called
|
||||
export const handlePrepare = async (request: URL | Request) => {
|
||||
const requestKey = getRequestKey(request);
|
||||
const existing = getExisting(requestKey);
|
||||
|
||||
if (existing?.canceled) {
|
||||
// Prepare overrides cancel - reset the canceled request
|
||||
pendingRequests.delete(requestKey);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRequest = async (request: URL | Request) => {
|
||||
const cacheKey = getCacheKey(request);
|
||||
const cachedResponse = await get(cacheKey);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
export const handleFetch = (request: URL | Request): Promise<Response> => {
|
||||
const requestKey = getRequestKey(request);
|
||||
const existing = getExisting(requestKey);
|
||||
|
||||
if (existing) {
|
||||
if (existing.canceled) {
|
||||
return Promise.resolve(new Response(undefined, { status: 204 }));
|
||||
}
|
||||
// Clone the response from the shared promise to avoid "Response is disturbed or locked" errors
|
||||
return existing.promise.then((response) => response.clone());
|
||||
}
|
||||
|
||||
try {
|
||||
const cancelToken = new AbortController();
|
||||
pendingRequests.set(cacheKey, cancelToken);
|
||||
const response = await fetch(request, { signal: cancelToken.signal });
|
||||
|
||||
assertResponse(response);
|
||||
put(cacheKey, response);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// No existing request, create a new one
|
||||
const controller = new AbortController();
|
||||
const promise = fetch(request, { signal: controller.signal }).catch((error: unknown) => {
|
||||
const standardError = error instanceof Error ? error : new Error(String(error));
|
||||
if (standardError.name === 'AbortError' || standardError.message === CANCELED_MESSAGE) {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
throw standardError;
|
||||
});
|
||||
|
||||
console.log('Not an abort error', error);
|
||||
pendingRequests.set(requestKey, {
|
||||
controller,
|
||||
promise,
|
||||
canceled: false,
|
||||
fetchStartedAt: Date.now(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRequests.delete(cacheKey);
|
||||
}
|
||||
// Clone for the first caller, so the promise retains the unconsumed original response for future callers
|
||||
return promise.then((response) => response.clone());
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
const requestKey = getRequestKey(url);
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
const pendingRequest = pendingRequests.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
// Mark existing request as canceled with timestamp
|
||||
pendingRequest.canceled = true;
|
||||
pendingRequest.canceledAt = Date.now();
|
||||
pendingRequest.controller.abort(CANCELED_MESSAGE);
|
||||
} else {
|
||||
// No pending request - create a pre-canceled placeholder
|
||||
const controller = new AbortController();
|
||||
controller.abort(CANCELED_MESSAGE);
|
||||
|
||||
const preCanceledRequest: PendingRequest = {
|
||||
controller,
|
||||
promise: Promise.resolve(new Response(undefined, { status: 204 })),
|
||||
canceled: true,
|
||||
canceledAt: Date.now(),
|
||||
};
|
||||
|
||||
pendingRequests.set(requestKey, preCanceledRequest);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user