// ── Support plugin ── // // Talks to a self-hosted support-relay (see support-relay/ in the repo). // Renders a full-screen overlay with the ticket inbox, the active // ticket's thread, a reply composer with KB-fragment search + insert, // and a "save as fragment" button. KB fragments are plain hyprcrd cards // in the configured KB bucket, searched via the plugin backend. const h = window.hyprcrd; const $ = id => document.getElementById(id); // ── State ─────────────────────────────────────────────────────── const state = { tickets: [], filter: '', // '', 'open', 'answered', 'closed' activeId: null, // relay_id of the selected ticket kbResults: [], composerDirty: false, }; // ── Overlay lifecycle ─────────────────────────────────────────── const overlay = document.getElementById('support-overlay'); function isOpen() { return overlay?.classList.contains('open'); } function show() { if (!overlay) return; overlay.classList.add('open'); refreshTickets(); // Hopefully poll right away so the user sees fresh data poll(); } function hide() { overlay?.classList.remove('open'); state.composerDirty = false; } window._supportToggle = () => (isOpen() ? hide() : show()); $('support-close')?.addEventListener('click', hide); $('support-poll')?.addEventListener('click', () => poll({ manual: true })); // Close with Escape (respects any open child dialogs) document.addEventListener('keydown', e => { if (e.key === 'Escape' && isOpen()) { e.preventDefault(); hide(); } }); // Filter pills overlay?.querySelectorAll('.support-pill').forEach(btn => { btn.addEventListener('click', () => { overlay.querySelectorAll('.support-pill').forEach(b => b.classList.toggle('active', b === btn)); state.filter = btn.dataset.filter || ''; renderList(); }); }); // ── API wrappers ──────────────────────────────────────────────── async function api(path, opts = {}) { const res = await fetch(path, { method: opts.method || 'GET', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': h?.util?.csrf?.() || '' }, body: opts.body ? JSON.stringify(opts.body) : undefined, }); const data = await res.json().catch(() => ({ error: 'bad response' })); if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status)); return data; } async function poll({ manual = false } = {}) { try { const res = await api('api/support/poll', { method: 'POST' }); if (manual) toast(`Pulled ${res.pulled || 0} update(s)`); await refreshTickets(); if (state.activeId) await refreshActive(); } catch (e) { if (manual) toast('Poll failed: ' + e.message, 'err'); } } async function refreshTickets() { try { const res = await api('api/support/tickets'); state.tickets = res.tickets || []; renderList(); } catch (e) { $('support-list').innerHTML = `
Couldn't load: ${escapeHtml(e.message)}
`; } } async function refreshActive() { if (!state.activeId) return; try { const res = await api('api/support/tickets/' + encodeURIComponent(state.activeId)); renderDetail(res.ticket); } catch (e) { $('support-detail').innerHTML = `
${escapeHtml(e.message)}
`; } } // ── Rendering ─────────────────────────────────────────────────── function renderList() { const host = $('support-list'); if (!host) return; const shown = state.filter ? state.tickets.filter(t => t.status === state.filter) : state.tickets; if (!shown.length) { host.innerHTML = `
No tickets${state.filter ? ' with status ' + state.filter : ''}.
`; return; } host.innerHTML = shown.map(t => `
${escapeHtml(t.issue_id)} ${escapeHtml(t.status)}
${escapeHtml(t.subject || '(no subject)')}
${escapeHtml(t.email)} ${escapeHtml(formatRel(t.last_reply_at || t.updated_at))}
`).join(''); host.querySelectorAll('.support-ticket').forEach(el => { el.addEventListener('click', () => openTicket(parseInt(el.dataset.id))); }); } function renderDetail(t) { const host = $('support-detail'); if (!host) return; if (!t) { host.innerHTML = `

Pick a ticket on the left.

`; return; } const replies = (t.replies || []).map(r => `
${r.direction === 'out' ? 'sent' : 'received'} ${escapeHtml(r.created_at)}
${escapeHtml(r.body)}
`).join(''); host.innerHTML = `

${escapeHtml(t.subject || '(no subject)')}

${escapeHtml(t.issue_id)} ${escapeHtml(t.status)} opened ${escapeHtml(formatRel(t.created_at))}
${replies || '
No messages yet.
'}
Reply
`; bindComposer(t); } function bindComposer(t) { const kbInput = $('support-kb-input'); const kbResults = $('support-kb-results'); const kbToggle = $('support-kb-toggle'); const body = $('support-reply-body'); const sendBtn = $('support-send-btn'); const saveFrag = $('support-save-fragment'); const closeBtn = $('support-status-closed'); const statusEl = $('support-composer-status'); // KB search: debounced let kbTimer = null; async function runKb(q) { try { const res = await api('api/support/kb?q=' + encodeURIComponent(q || '')); state.kbResults = res.fragments || []; kbResults.classList.toggle('open', !!state.kbResults.length); kbResults.innerHTML = state.kbResults.map((f, i) => `
${escapeHtml(f.title)}
${escapeHtml(stripMd(f.body).slice(0, 140))}
`).join('') || `
No fragments yet.
`; kbResults.querySelectorAll('.support-kb-result').forEach(el => { el.addEventListener('click', () => { const frag = state.kbResults[parseInt(el.dataset.idx)]; insertAtCursor(body, frag.body); kbResults.classList.remove('open'); kbInput.value = ''; }); }); } catch (e) { kbResults.innerHTML = `
${escapeHtml(e.message)}
`; kbResults.classList.add('open'); } } kbInput.addEventListener('input', () => { clearTimeout(kbTimer); kbTimer = setTimeout(() => runKb(kbInput.value.trim()), 120); }); kbInput.addEventListener('focus', () => runKb(kbInput.value.trim())); kbToggle.addEventListener('click', () => { if (kbResults.classList.contains('open')) kbResults.classList.remove('open'); else runKb(kbInput.value.trim()); }); // Close KB results when clicking outside document.addEventListener('click', e => { if (!overlay.contains(e.target)) return; if (kbResults.contains(e.target) || e.target === kbInput || e.target === kbToggle || kbToggle.contains(e.target)) return; kbResults.classList.remove('open'); }); sendBtn.addEventListener('click', async () => { const text = body.value.trim(); if (!text) { setStatus('Nothing to send.', 'err'); return; } sendBtn.disabled = true; setStatus('Sending…'); try { await api('api/support/tickets/' + t.relay_id + '/reply', { method: 'POST', body: { body: text } }); body.value = ''; setStatus('Sent.', 'ok'); await refreshTickets(); await refreshActive(); } catch (e) { setStatus('Failed: ' + e.message, 'err'); } finally { sendBtn.disabled = false; } }); saveFrag.addEventListener('click', async () => { const text = body.value.trim(); if (!text) { setStatus('Type something first.', 'err'); return; } const title = prompt('Fragment title?', text.split('\n')[0].slice(0, 60)); if (title === null) return; try { await api('api/support/kb', { method: 'POST', body: { title: title || '', body: text } }); setStatus('Saved to fragment library.', 'ok'); // Refresh KB list if open if (kbResults.classList.contains('open')) runKb(kbInput.value.trim()); } catch (e) { setStatus('Save failed: ' + e.message, 'err'); } }); closeBtn.addEventListener('click', async () => { try { await api('api/support/tickets/' + t.relay_id + '/status', { method: 'PATCH', body: { status: 'closed' } }); await refreshTickets(); await refreshActive(); } catch (e) { setStatus('Close failed: ' + e.message, 'err'); } }); function setStatus(msg, kind = '') { statusEl.textContent = msg; statusEl.className = 'support-composer-status' + (kind ? ' ' + kind : ''); if (kind === 'ok') setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'support-composer-status'; }, 3500); } } async function openTicket(id) { state.activeId = id; renderList(); await refreshActive(); } // ── Helpers ───────────────────────────────────────────────────── function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } /** Strip Markdown markers for preview purposes (headings, emphasis). */ function stripMd(s) { return String(s || '') .replace(/^#+\s+/gm, '') .replace(/[*_`]/g, '') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\s+/g, ' ') .trim(); } /** Human-friendly timestamp: "just now", "14m ago", "2h ago", "Tue 14:02". */ function formatRel(iso) { if (!iso) return ''; const d = new Date(iso.replace(' ', 'T') + (iso.includes('T') || iso.endsWith('Z') ? '' : 'Z')); if (isNaN(d.getTime())) return iso; const now = new Date(); const diff = (now - d) / 1000; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; if (diff < 86400 * 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString(); } function insertAtCursor(textarea, text) { const start = textarea.selectionStart, end = textarea.selectionEnd; const before = textarea.value.slice(0, start); const after = textarea.value.slice(end); const prefix = (before && !before.endsWith('\n')) ? '\n' : ''; textarea.value = before + prefix + text + after; const caret = (before + prefix + text).length; textarea.focus(); textarea.setSelectionRange(caret, caret); } function toast(msg, kind = 'info') { // Piggy-back on the simple notification system from relay sync const el = document.createElement('div'); el.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;font-family:var(--t-font-pixel);font-size:10px;color:' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';'; el.textContent = msg; document.body.appendChild(el); setTimeout(() => { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 350); }, 3500); } // ── Header button ─────────────────────────────────────────────── function injectHeaderButton() { const tasksBtn = document.getElementById('tasks-btn'); if (!tasksBtn || document.getElementById('support-btn')) return; const btn = document.createElement('button'); btn.id = 'support-btn'; btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; btn.innerHTML = 'Support'; btn.addEventListener('click', () => window._supportToggle?.()); tasksBtn.parentNode.insertBefore(btn, tasksBtn); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', injectHeaderButton); } else { injectHeaderButton(); } // ── Settings tab in the main settings overlay ─────────────────── function renderSettingsPanel(panelEl) { panelEl.innerHTML = `

Connect hyprcrd to your support relay. The relay hosts the public form and sends outgoing replies via SMTP; hyprcrd mirrors tickets and lets you reply from any card.

Leave empty to keep the currently stored key.
Where newly-pulled ticket cards will land.
Cards in this bucket become reusable reply fragments.

Relay config

Editable post-setup values on the connected relay. Mail footer supports {brand}, {issue_id}, {public_url} placeholders — lines containing an empty placeholder are dropped.

`; // Populate bucket selects from state.buckets const opts = (window.state?.buckets || []).map(b => ``).join(''); panelEl.querySelector('#support-set-inbox').innerHTML += opts; panelEl.querySelector('#support-set-kb').innerHTML += opts; api('api/support/settings').then(cur => { panelEl.querySelector('#support-set-url').value = cur.relay_url || ''; panelEl.querySelector('#support-set-key').placeholder = cur.has_key ? '•••••••• (saved — leave empty to keep)' : 'paste the key shown by the relay setup'; if (cur.bucket_inbox) panelEl.querySelector('#support-set-inbox').value = cur.bucket_inbox; if (cur.bucket_kb) panelEl.querySelector('#support-set-kb').value = cur.bucket_kb; }).catch(() => {}); const statusEl = panelEl.querySelector('#support-set-status'); function setStatus(msg, kind = '') { statusEl.textContent = msg; statusEl.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)'; } panelEl.querySelector('#support-set-save').addEventListener('click', async () => { const payload = { relay_url: panelEl.querySelector('#support-set-url').value.trim(), bucket_inbox: parseInt(panelEl.querySelector('#support-set-inbox').value) || 0, bucket_kb: parseInt(panelEl.querySelector('#support-set-kb').value) || 0, }; const key = panelEl.querySelector('#support-set-key').value; if (key) payload.relay_key = key; try { await api('api/support/settings', { method: 'POST', body: payload }); setStatus('Saved.', 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); panelEl.querySelector('#support-set-test').addEventListener('click', async () => { setStatus('Testing…'); try { const r = await api('api/support/settings/test', { method: 'POST' }); setStatus('OK — relay says "' + (r.brand || 'support') + '"', 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); // Relay config (brand / public url / footer). Loaded after a tick so // the local-settings render happens first — keeps this lazy, and // silently hides if the relay isn't reachable. const rsStatus = panelEl.querySelector('#support-rs-status'); function setRsStatus(msg, kind = '') { rsStatus.textContent = msg; rsStatus.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)'; } (async () => { try { const rs = await api('api/support/relay-settings'); panelEl.querySelector('#support-rs-brand').value = rs.brand || ''; panelEl.querySelector('#support-rs-public').value = rs.public_url || ''; panelEl.querySelector('#support-rs-footer').value = rs.mail_footer || ''; const def = panelEl.querySelector('#support-rs-footer-default'); if (def && rs.default_footer) def.textContent = 'Default if left empty: ' + rs.default_footer.replace(/\n/g, ' ⏎ '); } catch (e) { setRsStatus('(relay unreachable — configure above first)', 'err'); } })(); panelEl.querySelector('#support-rs-save').addEventListener('click', async () => { setRsStatus('Saving…'); try { await api('api/support/relay-settings', { method: 'POST', body: { brand: panelEl.querySelector('#support-rs-brand').value.trim(), public_url: panelEl.querySelector('#support-rs-public').value.trim(), mail_footer: panelEl.querySelector('#support-rs-footer').value, }}); setRsStatus('Saved.', 'ok'); } catch (e) { setRsStatus(e.message, 'err'); } }); } h?.settings?.registerTab?.({ id: 'support', label: 'support', icon: 'ph-lifebuoy', onShow: renderSettingsPanel, }); // Hook the sync tick so each user-triggered sync also pulls support. h?.hooks?.on?.('sync.tick', () => { poll().catch(() => {}); }); h?.registerPlugin?.({ name: 'support', version: '1.0.0' });