import type { Advertisement } from '../api/types'; import { recordList, safeUrl, setAdClickData } from '../api/protocol'; const AGE_KEY = 'hg_age_ok'; const DISMISSED_KEY = 'dialogAdDismissed'; const PROGRESS_KEY = 'dialogAdProgress'; const GRID_DISMISSED_KEY = 'gridAdDismissed'; function advertisements(list: unknown): Advertisement[] { return recordList(list).map(item => ({...item, id: String(item.advertiseCode || '').trim() || String(item.id || '').trim(), images: safeUrl(item.images), link: safeUrl(item.link), title: String(item.title || '').trim(), })).filter(item => item.images); } type SessionStore = Pick; export interface StartupView { showAge(): void; closeAge(): void; prepareAdvertisement(): void; loadAdvertisement(item: Advertisement, signal: AbortSignal): Promise; showAdvertisement(item: Advertisement): void; hideAdvertisement(): void; loadGrid(items: Advertisement[], signal: AbortSignal): Promise; showGrid(items: Advertisement[]): void; hideGrid(): void; } /** Session keys, signature version and resume behavior for JHA-1047. */ export class StartupDialogs { private accepted = false; private items: Advertisement[] = []; private signature = ''; private index = 0; private readyIndex = -1; private visibleIndex = -1; private displayed = false; private generation = 0; private loading?: AbortController; private advertisementPending = false; private gridSignature = ''; private gridItems: Advertisement[] = []; private gridLoading?: AbortController; private gridVisible = false; private gridDismissed = false; constructor(private storage: SessionStore, private view: StartupView, confirmed = false) { this.accepted = confirmed || this.read(AGE_KEY) === '1'; if (this.accepted) view.closeAge(); else view.showAge(); } private read(key: string) {try {return this.storage.getItem(key) || '';} catch {return '';}} private write(key: string, value: string) {try {this.storage.setItem(key, value);} catch {}} private remove(key: string) {try {this.storage.removeItem(key);} catch {}} confirmAge() { this.write(AGE_KEY, '1'); this.accepted = true; this.view.closeAge(); this.showReady(); this.showGridReady(); } setAdvertisements(list: Advertisement[], gridList: Advertisement[] = []) { const items = advertisements(list); const signature = items.length ? '2|' + items.map((item, index) => item.id || item.images || String(index)).join('|') : ''; const changed = signature !== this.signature; if (changed) { this.generation++; this.loading?.abort(); this.view.hideAdvertisement(); this.view.hideGrid(); this.gridVisible = false; this.items = items; this.signature = signature; this.readyIndex = this.visibleIndex = -1; this.displayed = false; this.advertisementPending = !!items.length && this.read(DISMISSED_KEY) !== signature; } this.setGridAdvertisements(gridList); if (changed && this.advertisementPending) void this.showItem(this.readProgress()); this.showGridReady(); } private setGridAdvertisements(list: Advertisement[]) { const items = advertisements(list); const signature = items.length ? JSON.stringify(items.map(item => [item.id, item.images, item.link, item.title])) : ''; if (signature === this.gridSignature) return; this.gridLoading?.abort(); this.view.hideGrid(); this.gridVisible = false; this.gridItems = []; this.gridSignature = signature; this.gridDismissed = this.read(GRID_DISMISSED_KEY) === signature; if (!items.length || this.gridDismissed) return; const controller = this.gridLoading = new AbortController(); void this.view.loadGrid(items, controller.signal).then(loaded => { if (controller.signal.aborted) return; this.gridItems = loaded; this.showGridReady(); }).catch(() => { /* A failed grid must never block the page or the other dialogs. */ }); } private showGridReady() { if (!this.accepted || this.advertisementPending || this.gridVisible || this.gridDismissed || !this.gridItems.length) return; this.gridVisible = true; this.view.showGrid(this.gridItems); } dismissGrid() { if (!this.gridVisible) return; this.gridDismissed = true; this.gridVisible = false; this.write(GRID_DISMISSED_KEY, this.gridSignature); this.view.hideGrid(); } private readProgress() { try { const progress = JSON.parse(this.read(PROGRESS_KEY)); const value = Number(progress?.index); return progress?.signature === this.signature && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; } catch {return 0;} } private async showItem(index: number) { const generation = ++this.generation; this.loading?.abort(); this.readyIndex = this.visibleIndex = -1; this.index = index; if (index >= this.items.length) {this.finish(); return;} this.write(PROGRESS_KEY, JSON.stringify({signature: this.signature, index})); this.view.prepareAdvertisement(); const controller = this.loading = new AbortController(); try { await this.view.loadAdvertisement(this.items[index], controller.signal); if (controller.signal.aborted || generation !== this.generation) return; this.readyIndex = index; this.showReady(); } catch { if (!controller.signal.aborted && generation === this.generation) void this.showItem(index + 1); } } private showReady() { if (!this.accepted || this.readyIndex < 0 || this.visibleIndex === this.readyIndex) return; this.visibleIndex = this.readyIndex; this.displayed = true; this.view.showAdvertisement(this.items[this.readyIndex]); } nextAdvertisement() { if (!this.accepted || this.visibleIndex < 0) return; void this.showItem(this.visibleIndex + 1); } private finish() { if (this.displayed) this.write(DISMISSED_KEY, this.signature); else this.remove(DISMISSED_KEY); this.remove(PROGRESS_KEY); this.view.hideAdvertisement(); this.readyIndex = this.visibleIndex = -1; this.advertisementPending = false; this.showGridReady(); } } export function loadStartupImage(image: HTMLImageElement, source: string, signal: AbortSignal, timeoutMs = 12000): Promise { return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => finish(new Error('广告图片加载超时')), timeoutMs); function finish(error?: Error) { if (settled) return; settled = true; clearTimeout(timer); image.removeEventListener('load', loaded); image.removeEventListener('error', failed); signal.removeEventListener('abort', aborted); if (error) reject(error); else resolve(); } function loaded() {finish(image.naturalWidth > 0 ? undefined : new Error('广告图片无法解码'));} function failed() {finish(new Error('广告图片加载失败'));} function aborted() {finish(new DOMException('Aborted', 'AbortError'));} function check() {if (image.complete) loaded();} image.addEventListener('load', loaded); image.addEventListener('error', failed); signal.addEventListener('abort', aborted, {once: true}); if (signal.aborted) {aborted(); return;} try { if (window.processImage) { Promise.resolve(window.processImage(source, image)).then(result => { if (!result) finish(new Error('广告图片解码失败')); else check(); }).catch(error => finish(error instanceof Error ? error : new Error('广告图片解码失败'))); } else {image.src = source; check();} } catch (error) {finish(error instanceof Error ? error : new Error('广告图片加载失败'));} }); } export function startStartupDialogs(): StartupDialogs { const age = document.getElementById('startup-age') as HTMLDialogElement; const ad = document.getElementById('startup-ad') as HTMLDialogElement; const grid = document.getElementById('startup-grid') as HTMLDialogElement; const promotions = grid.querySelector('.startup-promotions')!; let gridLinks = new Map(); let image = ad.querySelector('.startup-ad-image')!; const media = ad.querySelector('.startup-ad-media')!; const loading = ad.querySelector('.startup-ad-loading')!; const close = ad.querySelector('.startup-close')!; const view: StartupView = { showAge() {if (!age.open) age.showModal();}, closeAge() { const wasOpen = age.open; if (wasOpen) age.close(); document.documentElement.classList.add('eighteen-ok'); if (wasOpen) document.dispatchEvent(new CustomEvent('eighteen:closed')); }, prepareAdvertisement() {media.hidden = true; loading.hidden = false; close.disabled = true; ad.setAttribute('aria-busy', 'true');}, async loadAdvertisement(item, signal) { const pending = image.cloneNode(false) as HTMLImageElement; pending.removeAttribute('src'); pending.setAttribute('fetchpriority', 'high'); await loadStartupImage(pending, item.images, signal); if (signal.aborted) return; image.replaceWith(pending); image = pending; }, showAdvertisement(item) { image.alt = image.title = item.title?.trim() || '推荐广告'; media.setAttribute('aria-label', item.title || '查看广告'); if (item.link) media.href = item.link; else media.removeAttribute('href'); setAdClickData(media, item.id, item.location); media.hidden = false; loading.hidden = true; close.disabled = false; ad.removeAttribute('aria-busy'); ad.setAttribute('aria-label', item.title || '广告推荐'); if (!ad.open) ad.showModal(); }, hideAdvertisement() {if (ad.open) ad.close(); media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');}, async loadGrid(items, signal) { const links = new Map(); const loaded = await Promise.all(items.map(async item => { const link = document.createElement('a'); link.className = 'startup-promotion'; link.target = '_blank'; link.rel = 'sponsored nofollow noopener noreferrer'; link.setAttribute('aria-label', item.title || '查看推荐应用'); if (item.link) link.href = item.link; setAdClickData(link, item.id, item.location); const icon = document.createElement('img'); icon.width = icon.height = 80; icon.alt = item.title || '推荐应用'; icon.decoding = 'async'; const name = document.createElement('span'); name.textContent = item.title || '推荐应用'; link.append(icon, name); try { await loadStartupImage(icon, item.images, signal); links.set(item, link); return item; } catch {return null;} })); if (signal.aborted) return []; gridLinks = links; return loaded.filter((item): item is Advertisement => item !== null); }, showGrid(items) { promotions.replaceChildren(...items.map(item => gridLinks.get(item)!)); promotions.scrollTop = 0; if (!grid.open) grid.showModal(); }, hideGrid() {if (grid.open) grid.close();}, }; const storage: SessionStore = { getItem: key => window.sessionStorage.getItem(key), setItem: (key, value) => window.sessionStorage.setItem(key, value), removeItem: key => window.sessionStorage.removeItem(key), }; const startup = new StartupDialogs(storage, view, document.documentElement.classList.contains('eighteen-ok')); age.addEventListener('cancel', event => event.preventDefault()); age.querySelector('.startup-age-confirm')!.addEventListener('click', () => startup.confirmAge()); age.querySelectorAll('.startup-age-leave,.startup-close').forEach(button => button.addEventListener('click', () => location.assign('https://www.google.com'))); function nextAdvertisement() {startup.nextAdvertisement();} close.addEventListener('click', nextAdvertisement); ad.addEventListener('cancel', event => {event.preventDefault(); nextAdvertisement();}); grid.querySelector('.startup-close')!.addEventListener('click', () => startup.dismissGrid()); grid.addEventListener('cancel', event => {event.preventDefault(); startup.dismissGrid();}); promotions.addEventListener('click', event => { const target = event.target instanceof Element ? event.target : null; if (target?.closest('.startup-promotion[href]')) startup.dismissGrid(); }); [age, ad, grid].forEach(dialog => { dialog.addEventListener('keydown', event => {if (event.key === 'Tab') dialog.classList.add('startup-keyboard-focus');}); dialog.addEventListener('pointerdown', () => dialog.classList.remove('startup-keyboard-focus')); dialog.addEventListener('close', () => dialog.classList.remove('startup-keyboard-focus')); }); return startup; }