function createNotificationReadOutbox({ storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100, }) { const storageKey = 'stackchain.notification-read-outbox.v1'; function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); if (record?.version !== 1 || !Array.isArray(record.items)) return []; return record.items.filter(item => item?.kind === 'notification-read' && Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin); } catch (_error) { return []; } } function write(items, mirror = true) { storage?.setItem(storageKey, JSON.stringify({ version: 1, items })); coordinator?.notify('notification-read'); if (mirror && backgroundSync?.reconcile) { Promise.resolve(backgroundSync.reconcile(items, 'notification-read')) .then(() => items.length ? backgroundSync.requestSync?.() : undefined) .catch(() => { /* Foreground reconnect remains available. */ }); } } function itemId(ownerLogin, notificationId) { return 'notification-read:' + ownerLogin + ':' + notificationId; } async function enqueueDurably(notificationId) { notificationId = Number(notificationId); const ownerLogin = String(getOwnerLogin() || '').trim(); if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.'); if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.'); const items = read(); const id = itemId(ownerLogin, notificationId); let item = items.find(candidate => candidate.id === id); if (!item) { if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.'); item = { id, kind: 'notification-read', notificationId, ownerLogin, status: 'queued', queuedAt: Number(now()), }; items.push(item); write(items, false); } if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { return { item: { ...item }, background: false, durability: 'foreground-only' }; } try { await backgroundSync.reconcile(read(), 'notification-read'); await backgroundSync.requestSync(); return { item: { ...item }, background: true, durability: 'background' }; } catch (error) { return { item: { ...item }, background: false, durability: 'foreground-only', error }; } } function suppress(items, login = getOwnerLogin()) { const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim()) .map(item => item.notificationId)); return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id))); } async function send(item, currentLogin) { if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (backgroundSync?.send) return backgroundSync.send(item, currentLogin); await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', { method: 'PATCH', headers: { Accept: 'application/json' }, }); return { confirmed: true }; } async function flush(currentLogin) { currentLogin = String(currentLogin || '').trim(); const confirmed = []; let items = read(); for (const item of items) { if (item.ownerLogin !== currentLogin || item.status === 'attention') continue; try { const result = await send(item, currentLogin); if (result?.blocked || result?.busy) continue; if (result?.attention) { items = items.map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240), } : candidate); write(items); continue; } confirmed.push(item.notificationId); items = items.filter(candidate => candidate.id !== item.id); write(items); } catch (error) { const status = Number(error?.status || 0); if (status >= 400 && status < 500) { items = items.map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240), } : candidate); write(items); continue; } break; } } return { confirmed, remaining: read() }; } function reconcileBackground(records) { const states = new Map((records || []).filter(item => item?.kind === 'notification-read') .map(item => [item.id, item])); const items = read().flatMap(item => { const state = states.get(item.id); if (state?.status === 'sent') return []; if (state?.status === 'attention') return [{ ...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240), }]; return [item]; }); write(items); return items; } return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) }; } if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;