Large albums opened slowly and flickered; the default album cover was also lost in the media-objects refactor. Large-album loading: - quantize signed object URLs within a window and send Cache-Control so previews are reused instead of re-fetched - publish photo.updated (not photo.created) when variants are ready, so clients update in place without reordering the grid - worker concurrency + newest-first variant priority; raise default batch Album cover: - first uploaded photo becomes the cover when none is set - clear the cover when the cover photo is trashed or moved out - "set as album cover" in the photo context menu and actions panel OpenSpec changes: fix-large-album-load, fix-album-cover
79 lines
1.6 KiB
TypeScript
79 lines
1.6 KiB
TypeScript
export interface SSEEvent {
|
|
type: string;
|
|
data: unknown;
|
|
}
|
|
|
|
export interface SSEClientOptions {
|
|
path: string;
|
|
onEvent: (event: SSEEvent) => void;
|
|
onReconnect?: () => void;
|
|
}
|
|
|
|
// Wrapper над EventSource с экспоненциальным backoff и догоном через REST.
|
|
export class SSEClient {
|
|
private es: EventSource | null = null;
|
|
private stopped = false;
|
|
private attempt = 0;
|
|
private readonly knownEvents = [
|
|
'photo.created',
|
|
'photo.updated',
|
|
'photo.deleted',
|
|
'comment.created',
|
|
'like.added',
|
|
'like.removed',
|
|
'member.joined',
|
|
'member.left',
|
|
'member.updated',
|
|
'album.updated',
|
|
'album.deleted',
|
|
'notification'
|
|
];
|
|
|
|
constructor(private opts: SSEClientOptions) {}
|
|
|
|
connect(token: string) {
|
|
this.stopped = false;
|
|
this.open(token);
|
|
}
|
|
|
|
private open(token: string) {
|
|
if (this.stopped) return;
|
|
const url = `${this.opts.path}?token=${encodeURIComponent(token)}`;
|
|
this.es = new EventSource(url);
|
|
|
|
this.es.onopen = () => {
|
|
if (this.attempt > 0) {
|
|
this.opts.onReconnect?.();
|
|
}
|
|
this.attempt = 0;
|
|
};
|
|
|
|
for (const name of this.knownEvents) {
|
|
this.es.addEventListener(name, (ev) => {
|
|
let data: unknown = null;
|
|
try {
|
|
data = JSON.parse((ev as MessageEvent).data);
|
|
} catch {
|
|
data = (ev as MessageEvent).data;
|
|
}
|
|
this.opts.onEvent({ type: name, data });
|
|
});
|
|
}
|
|
|
|
this.es.onerror = () => {
|
|
this.es?.close();
|
|
this.es = null;
|
|
if (this.stopped) return;
|
|
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
|
|
this.attempt += 1;
|
|
setTimeout(() => this.open(token), delay);
|
|
};
|
|
}
|
|
|
|
close() {
|
|
this.stopped = true;
|
|
this.es?.close();
|
|
this.es = null;
|
|
}
|
|
}
|