// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on // log (auto-send on, recipient e-mail known, default template, not already // sent) and emits "qsl:autosend"; this component does the part Go can't — // render the card off-screen, rasterize it to JPEG and hand it back to // SendEQSL. Jobs are processed one at a time. Mounted once, near the app root. import { useCallback, useEffect, useRef, useState } from 'react'; import { EventsOn } from '../../../wailsjs/runtime/runtime'; import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App'; import type { RenderModel } from './qslTypes'; import { loadCardAssets, type CardAssets } from './qslAssets'; import { CardPreview } from './CardPreview'; import { rasterizeCard } from './rasterize'; interface Job { qsoId: number; templateId: number; callsign: string; } interface Props { onSent?: (callsign: string) => void; onError?: (message: string) => void; } export function AutoEQSL({ onSent, onError }: Props) { const queue = useRef([]); const busy = useRef(false); const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null); const svgEl = useRef(null); const sentForRef = useRef(null); // qsoId we've already fired SendEQSL for // Keep the callbacks in refs so they never change the effects' identity — a // toast/grid re-render from onSent must NOT re-run the send effect (that // re-sent the same eQSL many times in a row). const onSentRef = useRef(onSent); const onErrorRef = useRef(onError); useEffect(() => { onSentRef.current = onSent; onErrorRef.current = onError; }); // Pull the next job, fetch its render model + assets, then mount it (the // effect below rasterizes once the DOM has it). const pump = useCallback(async () => { if (busy.current) return; const job = queue.current.shift(); if (!job) return; busy.current = true; try { const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel; const assets = await loadCardAssets(model.template, job.templateId); setCurrent({ job, model, assets }); } catch (e) { onErrorRef.current?.(`Auto eQSL: ${e}`); busy.current = false; void pump(); } }, []); useEffect(() => { const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => { // Dedupe: ignore a repeat event for a QSO we're already handling/handled. if (sentForRef.current === p.qsoId || queue.current.some((j) => j.qsoId === p.qsoId)) return; queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign }); void pump(); }); return () => off(); }, [pump]); // Once a job is mounted off-screen, wait for fonts + paint, rasterize, send. // Sends exactly once per job (guarded by sentForRef), independent of renders. useEffect(() => { if (!current) return; if (sentForRef.current === current.job.qsoId) return; // already sent this one let cancelled = false; void (async () => { try { await document.fonts.ready; await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null)))); if (cancelled || !svgEl.current) return; sentForRef.current = current.job.qsoId; const card = current.model.template.card; const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg'); await SendEQSL(current.job.qsoId, current.job.templateId, jpeg); onSentRef.current?.(current.job.callsign); } catch (e) { onErrorRef.current?.(`Auto eQSL: ${e}`); } finally { if (!cancelled) { setCurrent(null); busy.current = false; void pump(); } } })(); return () => { cancelled = true; }; }, [current, pump]); if (!current) return null; // Off-screen at full card resolution so the rasterized output matches the // editor preview exactly. return (
{ svgEl.current = el; }} />
); }