diff --git a/src/options/options.html b/src/options/options.html index 9eb2432..f279614 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -521,12 +521,31 @@

Custom Domains

Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.

-
- +
+ +
+ + +
+ +
+
+ + + +

- When you click "Add Domain", your browser will ask you to confirm access. No extra steps needed. + When you add a domain, your browser will ask you to confirm access. No extra steps needed.

diff --git a/src/options/options.js b/src/options/options.js index 775555e..bdd57c1 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -280,6 +280,19 @@ document.addEventListener('DOMContentLoaded', async () => { $('#newDomain').addEventListener('keydown', (e) => { if (e.key === 'Enter') addDomain(); }); + renderSuggestedDomains(); + + // Bulk add domains + $('#btnBulkAddDomains').addEventListener('click', () => { + const section = $('#bulkDomainSection'); + section.style.display = section.style.display === 'none' ? 'block' : 'none'; + }); + $('#btnCancelBulkDomains').addEventListener('click', () => { + $('#bulkDomainSection').style.display = 'none'; + $('#bulkDomainText').value = ''; + $('#bulkDomainStatus').textContent = ''; + }); + $('#btnApplyBulkDomains').addEventListener('click', bulkAddDomains); // Settings listeners $('#showHighlights').addEventListener('change', async (e) => { @@ -566,44 +579,119 @@ async function renderLog() { } // --- Custom Domains --- -async function addDomain() { - let domain = $('#newDomain').value.trim(); - if (!domain) return; - // Normalize: ensure it has a protocol +// Suggested popular domains (not already built-in) +const SUGGESTED_DOMAINS = [ + { label: 'OpenWebUI', url: 'https://openwebui.local' }, + { label: 'Ollama Web', url: 'http://localhost:3000' }, + { label: 'text-generation-webui', url: 'http://localhost:7860' }, + { label: 'Jan.ai', url: 'https://jan.ai' }, + { label: 'You.com', url: 'https://you.com' }, + { label: 'Phind', url: 'https://www.phind.com' }, + { label: 'Cohere', url: 'https://coral.cohere.com' }, + { label: 'Mistral', url: 'https://chat.mistral.ai' }, + { label: 'Pi AI', url: 'https://pi.ai' }, + { label: 'Notion AI', url: 'https://www.notion.so' }, + { label: 'Quora', url: 'https://www.quora.com' }, + { label: 'Discord', url: 'https://discord.com' }, + { label: 'Slack', url: 'https://app.slack.com' }, + { label: 'Jira', url: 'https://atlassian.net' }, + { label: 'Linear', url: 'https://linear.app' }, + { label: 'Bitbucket', url: 'https://bitbucket.org' }, +]; + +function normalizeDomain(raw) { + let domain = raw.trim(); + if (!domain) return null; if (!domain.startsWith('http://') && !domain.startsWith('https://')) { domain = 'https://' + domain; } - // Strip trailing slashes - domain = domain.replace(/\/+$/, ''); + return domain.replace(/\/+$/, ''); +} +async function addSingleDomain(domain) { const domains = settings.customDomains || []; - if (domains.includes(domain)) { - alert('Domain already added.'); - return; - } + if (domains.includes(domain)) return { added: false, reason: 'duplicate' }; - // Request browser permission for this domain try { - const granted = await api.permissions.request({ - origins: [domain + '/*'], - }); - if (!granted) { - alert('Permission denied. The extension needs access to this domain to work.'); - return; - } + const granted = await api.permissions.request({ origins: [domain + '/*'] }); + if (!granted) return { added: false, reason: 'denied' }; } catch (e) { - // Firefox or older Chrome may not support optional permissions this way console.warn('[Silent Send] Could not request permission:', e); } domains.push(domain); settings.customDomains = domains; await Storage.saveSettings({ customDomains: domains }); + return { added: true }; +} + +async function addDomain() { + const domain = normalizeDomain($('#newDomain').value); + if (!domain) return; + + const result = await addSingleDomain(domain); + if (!result.added) { + if (result.reason === 'duplicate') alert('Domain already added.'); + else alert('Permission denied. The extension needs access to this domain to work.'); + return; + } + renderDomains(); + renderSuggestedDomains(); $('#newDomain').value = ''; } +async function bulkAddDomains() { + const text = $('#bulkDomainText').value; + const lines = text.split(/[\n,]+/).map(l => l.trim()).filter(Boolean); + if (lines.length === 0) return; + + let added = 0, skipped = 0; + for (const line of lines) { + const domain = normalizeDomain(line); + if (!domain) { skipped++; continue; } + const result = await addSingleDomain(domain); + if (result.added) added++; + else skipped++; + } + + renderDomains(); + renderSuggestedDomains(); + $('#bulkDomainStatus').textContent = `Added ${added}, skipped ${skipped}`; + if (added > 0) $('#bulkDomainText').value = ''; +} + +function renderSuggestedDomains() { + const container = $('#suggestedDomains'); + if (!container) return; + const domains = settings.customDomains || []; + + // Filter out suggestions that are already added + const available = SUGGESTED_DOMAINS.filter(s => !domains.includes(s.url)); + if (available.length === 0) { + safeHTML(container, 'All suggestions added!'); + return; + } + + safeHTML(container, available.map(s => + `` + ).join('')); + + container.querySelectorAll('.btn-suggest-domain').forEach(btn => { + btn.addEventListener('click', async () => { + const domain = btn.dataset.url; + const result = await addSingleDomain(domain); + if (result.added) { + renderDomains(); + renderSuggestedDomains(); + } else if (result.reason === 'denied') { + alert('Permission denied for ' + domain); + } + }); + }); +} + function renderDomains() { const list = $('#domainList'); const domains = settings.customDomains || []; @@ -616,12 +704,86 @@ function renderDomains() { safeHTML(list, domains .map((d, i) => `
- ${escapeHtml(d)} - + ${escapeHtml(d)} +
+ + +
`) .join('')); + // Edit handlers + list.querySelectorAll('.btn-edit-domain').forEach((btn) => { + btn.addEventListener('click', async () => { + const idx = parseInt(btn.dataset.index, 10); + const domains = settings.customDomains || []; + const current = domains[idx]; + const row = btn.closest('.domain-item'); + const textEl = row.querySelector('.domain-text'); + + // Replace text with input + const input = document.createElement('input'); + input.type = 'text'; + input.value = current; + input.style.cssText = 'flex:1;font-size:12px;font-family:monospace;padding:3px 6px;border:1px solid #3b82f6;border-radius:4px;outline:none;min-width:0'; + textEl.replaceWith(input); + input.focus(); + input.select(); + + // Replace edit button with save button + btn.textContent = '\u2713'; + btn.title = 'Save'; + btn.style.color = '#10b981'; + + const save = async () => { + const newDomain = normalizeDomain(input.value); + if (!newDomain || newDomain === current) { + renderDomains(); + return; + } + + if (domains.includes(newDomain)) { + alert('Domain already exists.'); + renderDomains(); + return; + } + + // Request permission for new domain + try { + const granted = await api.permissions.request({ origins: [newDomain + '/*'] }); + if (!granted) { + alert('Permission denied for ' + newDomain); + renderDomains(); + return; + } + } catch (e) { /* non-fatal */ } + + // Revoke old permission + try { + await api.permissions.remove({ origins: [current + '/*'] }); + } catch (e) { /* non-fatal */ } + + domains[idx] = newDomain; + settings.customDomains = domains; + await Storage.saveSettings({ customDomains: domains }); + renderDomains(); + renderSuggestedDomains(); + }; + + btn.onclick = save; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') save(); + if (e.key === 'Escape') renderDomains(); + }); + input.addEventListener('blur', () => { + // Small delay to allow button click to fire first + setTimeout(() => { if (document.contains(input)) renderDomains(); }, 150); + }); + }); + }); + + // Remove handlers list.querySelectorAll('.btn-remove-domain').forEach((btn) => { btn.addEventListener('click', async () => { const idx = parseInt(btn.dataset.index, 10); @@ -630,7 +792,6 @@ function renderDomains() { settings.customDomains = domains; await Storage.saveSettings({ customDomains: domains }); - // Revoke browser permission for the removed domain if (removed) { try { await api.permissions.remove({ origins: [removed + '/*'] }); @@ -638,6 +799,7 @@ function renderDomains() { } renderDomains(); + renderSuggestedDomains(); }); }); } diff --git a/src/popup/popup.html b/src/popup/popup.html index 67048fe..ff3221f 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -244,6 +244,19 @@
+
+
+ Custom Domains + Add sites beyond the built-in list +
+
+
+ + +
+
+
+

Sync, encryption, org, version history, import/export, and more

diff --git a/src/popup/popup.js b/src/popup/popup.js index 780f0be..96a1109 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -244,6 +244,15 @@ async function initUnlockedUI() { }); } + // --- Popup domain management --- + renderPopupDomains(); + renderPopupDomainSuggestions(); + + $('#btnPopupAddDomain').addEventListener('click', popupAddDomain); + $('#popupNewDomain').addEventListener('keydown', (e) => { + if (e.key === 'Enter') popupAddDomain(); + }); + // Update privacy note based on encryption state const encEnabled = await Storage._isAtRestEncryptionEnabled(); const encNote = $('#privacyEncNote'); @@ -908,6 +917,114 @@ function updateStatusDot() { dot.classList.toggle('disabled', !settings.enabled); } +// --- Popup Domain Management --- + +const POPUP_SUGGESTED_DOMAINS = [ + { label: 'Mistral', url: 'https://chat.mistral.ai' }, + { label: 'Cohere', url: 'https://coral.cohere.com' }, + { label: 'Phind', url: 'https://www.phind.com' }, + { label: 'You.com', url: 'https://you.com' }, + { label: 'Pi AI', url: 'https://pi.ai' }, + { label: 'Discord', url: 'https://discord.com' }, + { label: 'Slack', url: 'https://app.slack.com' }, + { label: 'Notion', url: 'https://www.notion.so' }, + { label: 'Linear', url: 'https://linear.app' }, + { label: 'Bitbucket', url: 'https://bitbucket.org' }, +]; + +function normalizeDomain(raw) { + let d = raw.trim(); + if (!d) return null; + if (!d.startsWith('http://') && !d.startsWith('https://')) d = 'https://' + d; + return d.replace(/\/+$/, ''); +} + +async function popupAddDomain() { + const domain = normalizeDomain($('#popupNewDomain').value); + if (!domain) return; + + const domains = settings.customDomains || []; + if (domains.includes(domain)) return; + + try { + const granted = await api.permissions.request({ origins: [domain + '/*'] }); + if (!granted) return; + } catch (e) { /* non-fatal */ } + + domains.push(domain); + settings.customDomains = domains; + await Storage.saveSettings({ customDomains: domains }); + $('#popupNewDomain').value = ''; + renderPopupDomains(); + renderPopupDomainSuggestions(); +} + +function renderPopupDomains() { + const list = $('#popupDomainList'); + if (!list) return; + const domains = settings.customDomains || []; + + if (domains.length === 0) { + safeHTML(list, '
No custom domains added
'); + return; + } + + safeHTML(list, domains.map((d, i) => ` +
+ ${escapeHtml(d)} + +
+ `).join('')); + + list.querySelectorAll('.btn-popup-remove-domain').forEach(btn => { + btn.addEventListener('click', async () => { + const idx = parseInt(btn.dataset.index, 10); + const domains = settings.customDomains || []; + const removed = domains.splice(idx, 1)[0]; + settings.customDomains = domains; + await Storage.saveSettings({ customDomains: domains }); + if (removed) { + try { await api.permissions.remove({ origins: [removed + '/*'] }); } catch (e) { /* non-fatal */ } + } + renderPopupDomains(); + renderPopupDomainSuggestions(); + }); + }); +} + +function renderPopupDomainSuggestions() { + const container = $('#popupDomainSuggestions'); + if (!container) return; + const domains = settings.customDomains || []; + + const available = POPUP_SUGGESTED_DOMAINS.filter(s => !domains.includes(s.url)); + if (available.length === 0) { + container.replaceChildren(); + return; + } + + safeHTML(container, available.slice(0, 6).map(s => + `` + ).join('')); + + container.querySelectorAll('.btn-popup-suggest').forEach(btn => { + btn.addEventListener('click', async () => { + const domain = btn.dataset.url; + const domains = settings.customDomains || []; + if (domains.includes(domain)) return; + try { + const granted = await api.permissions.request({ origins: [domain + '/*'] }); + if (!granted) return; + } catch (e) { /* non-fatal */ } + domains.push(domain); + settings.customDomains = domains; + await Storage.saveSettings({ customDomains: domains }); + renderPopupDomains(); + renderPopupDomainSuggestions(); + }); + }); +} + // --- Util --- function escapeHtml(str) { const div = document.createElement('div');