From 166e220343db77e9aec353bee22357992a0f082e Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Sat, 3 Oct 2026 00:09:44 +0200 Subject: [PATCH] Theseus 0.3.68: quick-links strip opens services in a dedicated side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking an icon on the left strip now opens the service inside a dedicated 380-px mini-view (quickPanel) next to the strip, Opera-style, instead of a new full-sized tab. Click the active icon again to close the panel; click a different one to switch. If the panel is already pointed at the same host, we skip the loadURL so scroll position, open chat and login state survive a close+reopen round-trip. Icons now paint as real brand SVGs (Messenger, WhatsApp, Telegram, X, YouTube, Spotify) with their official colours, bundled inside quicklinks.html so no external favicon fetch leaks the fact that the strip is loaded. Unknown ids fall back to a letter chip. The strip vertically centres the icons between two flex spacers to match Opera's layout. Defaults ship Messenger, WhatsApp, Telegram, X, YouTube and Spotify. The Settings › General › Quick links section still lists / adds / removes entries and toggles the strip. --- main.js | 109 +++++++++++++++++++++++++++++------------- package.json | 2 +- quicklinks-preload.js | 5 +- quicklinks.html | 90 +++++++++++++++++++--------------- 4 files changed, 131 insertions(+), 75 deletions(-) diff --git a/main.js b/main.js index 6e6c78b2..63edaebc 100644 --- a/main.js +++ b/main.js @@ -503,9 +503,12 @@ const SETTINGS_DEFAULTS = { // service shortcuts; click opens the URL in a new tab. Toggle via settings. quickLinksShow: true, quickLinks: [ - { id: "x", url: "https://x.com/", title: "X" }, - { id: "telegram", url: "https://web.telegram.org/", title: "Telegram" }, - { id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" }, + { id: "messenger", url: "https://www.messenger.com/", title: "Messenger" }, + { id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" }, + { id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" }, + { id: "x", url: "https://x.com/", title: "X" }, + { id: "youtube", url: "https://www.youtube.com/", title: "YouTube" }, + { id: "spotify", url: "https://open.spotify.com/", title: "Spotify" }, ], // Toolbar bar sizes. The URL bar is flex:1 by default so it eats all // remaining space; `compact`/`medium` cap it so the extension dock has @@ -1920,8 +1923,11 @@ let linkStatusW = 100, linkStatusH = 22; // nothing until the user actively opens it, so the perf cost of an unused // add-on is nil. let sidebar, sidebarVisible = false, sidebarActivePanelId = null; -let quicklinks; // left-edge quick-links strip (Opera-style) -const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles +let quicklinks; // left-edge quick-links strip (Opera-style) +const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles +let quickPanel; // the mini dedicated view for the active quick-link +const QUICK_PANEL_W = 380; // Opera's sidebar panel sits around this width +let activeQuickLinkId = null; // id of the quick-link whose panel is open, or null // Sidebar width is user-adjustable via a drag grip on the panel's left edge. // The value below is the default; settings.sidebarWidth overrides it once // loadSettings() runs and persists any drag adjustment made by the user. @@ -2736,16 +2742,24 @@ function layout() { quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH }); try { quicklinks.setVisible(leftW > 0); } catch {} } + // Quick-link panel: a narrow mini-view just right of the strip, showing the + // active quick-link's web app. Only takes space when something is open. + const panelW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0; + if (quickPanel) { + quickPanel.setBounds({ x: leftW, y: chromeH, width: panelW, height: bodyH }); + try { quickPanel.setVisible(panelW > 0); } catch {} + } // Sidebar (when visible) claims a fixed slice on the right; the tab views // shrink to fit alongside it. When hidden, tabs get the full width. const sideW = sidebarVisible && !fsTab ? sidebarW : 0; - const tabW = Math.max(0, width - leftW - sideW); - for (const t of tabs) t.view.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); - if (sidebar) sidebar.setBounds({ x: leftW + tabW, y: chromeH, width: sideW, height: bodyH }); + const tabX = leftW + panelW; + const tabW = Math.max(0, width - tabX - sideW); + for (const t of tabs) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (sidebar) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH }); // Approval overlay sits exactly over the tab area — the page underneath // keeps running; only pointer input is intercepted. - if (approvalPop) approvalPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); - if (jsDialogPop) jsDialogPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); + if (approvalPop) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); positionPopover(); positionEnginePicker(); positionDownloads(); @@ -3788,12 +3802,23 @@ function createWindow() { sidebar.setVisible(false); // Opera-style quick-links strip on the left edge: a thin vertical column // with icons for a few web apps (X, Telegram, WhatsApp by default). Clicking - // one opens its URL in a new tab (or focuses an existing tab for that URL). + // one opens that service in a dedicated mini-tab (the quickPanel next door) + // rather than a full-sized tab. quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } }); try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {} win.contentView.addChildView(quicklinks); styleScrollbars(quicklinks.webContents); quicklinks.webContents.loadFile("quicklinks.html"); + // Dedicated mini-view that renders the currently-active quick-link in its + // own narrow column, Opera-style. Lives permanently in the window; shown / + // hidden via activeQuickLinkId. Separate browsing context from any tab, + // so a Facebook sidebar visit doesn't share cookies with a Facebook tab + // the user opened — matching how Opera's sidebar panels feel. + quickPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "home-preload.js") } }); + try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} + win.contentView.addChildView(quickPanel); + styleScrollbars(quickPanel.webContents); + quickPanel.setVisible(false); // Add-on approval overlay (approval-modal capability). Transparent view // over the tab area, loaded once, shown per request. approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); @@ -6282,11 +6307,15 @@ ipcMain.handle("settings-set", (_e, key, val) => { const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null; try { wc && wc.reload(); } catch {} } - // Quick-links strip: re-layout + push the new list to the strip's view so - // show/hide and link edits take effect without a relaunch. + // Quick-links strip: re-layout + push the new state to the strip's view so + // show/hide and link edits take effect without a relaunch. If the active + // panel's link was removed, close the panel. if (key === "quickLinksShow" || key === "quickLinks") { + if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) { + activeQuickLinkId = null; + } layout(); - try { quicklinks?.webContents.send("quicklinks-update", settings.quickLinks || []); } catch {} + emitQuickLinksState(); } // Broadcast to every renderer that watches the live settings — the chrome // (toolbar sizes, URL-bar language chip) and every open settings tab (so @@ -6295,29 +6324,43 @@ ipcMain.handle("settings-set", (_e, key, val) => { broadcastSettings(); return settings; }); -// Quick-links IPC: read the saved list, open one in a new tab (focus an -// existing tab for that host if present), or prompt for a URL to add. -ipcMain.handle("quicklinks-get", () => settings.quickLinks || []); -ipcMain.handle("quicklinks-open", (_e, id) => { +// Quick-links IPC. getState: list + whichever one is currently open. open: a +// toggle — click to open the panel on that link, click the active one again +// to close it, click a different one to switch. The strip subscribes via +// onState so it can highlight the active icon. +function emitQuickLinksState() { + const payload = { links: settings.quickLinks || [], openId: activeQuickLinkId }; + try { quicklinks?.webContents.send("quicklinks-state", payload); } catch {} +} +function openQuickPanel(id) { const L = (settings.quickLinks || []).find((x) => x.id === id); - if (!L || !L.url) return false; + if (!quickPanel || !L || !L.url) return; + activeQuickLinkId = id; try { - // Focus an existing tab whose current URL is on the same host — avoids - // stacking a new tab every click. - const u = new URL(L.url); - const existing = tabs.find((t) => { try { return t.url && new URL(t.url).host === u.host; } catch { return false; } }); - if (existing) { setActive(existing.id); return true; } - } catch {} - createTab(L.url); - return true; -}); -ipcMain.handle("quicklinks-add", async () => { - // Minimal add flow for now: prompt via a native input dialog isn't ideal - // in Electron, so route the user to Settings where they can edit the list - // directly. (A proper picker UI can land later.) - openSettingsTab("general"); + const cur = quickPanel.webContents.getURL(); + // Only re-navigate when the panel isn't already showing this origin — + // avoids blowing away the user's state (open chat, scroll position) + // when they click the icon to reopen the panel they just closed. + const sameHost = (() => { try { return cur && new URL(cur).host === new URL(L.url).host; } catch { return false; } })(); + if (!sameHost) quickPanel.webContents.loadURL(L.url); + } catch (e) { console.warn("[quicklinks] loadURL failed:", e?.message); } + layout(); + emitQuickLinksState(); +} +function closeQuickPanel() { + activeQuickLinkId = null; + layout(); + emitQuickLinksState(); +} +ipcMain.handle("quicklinks-get-state", () => ({ links: settings.quickLinks || [], openId: activeQuickLinkId })); +ipcMain.handle("quicklinks-open", (_e, id) => { + if (!id) return false; + if (id === activeQuickLinkId) { closeQuickPanel(); return true; } + openQuickPanel(String(id)); return true; }); +ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); return true; }); +ipcMain.handle("quicklinks-add", () => { openSettingsTab("general"); return true; }); function broadcastSettings() { try { chrome?.webContents.send("settings-update", settings); } catch {} for (const t of tabs) { diff --git a/package.json b/package.json index f927deb2..407ed3ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.67", + "version": "0.3.68", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", diff --git a/quicklinks-preload.js b/quicklinks-preload.js index a0ccea8a..0033f6cc 100644 --- a/quicklinks-preload.js +++ b/quicklinks-preload.js @@ -1,7 +1,8 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("quicklinks", { - getLinks: () => ipcRenderer.invoke("quicklinks-get"), + getState: () => ipcRenderer.invoke("quicklinks-get-state"), open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")), + close: () => ipcRenderer.invoke("quicklinks-close"), add: () => ipcRenderer.invoke("quicklinks-add"), - onLinks: (cb) => ipcRenderer.on("quicklinks-update", (_e, d) => cb(d)), + onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)), }); diff --git a/quicklinks.html b/quicklinks.html index 81b81700..2eb21b9e 100644 --- a/quicklinks.html +++ b/quicklinks.html @@ -5,78 +5,90 @@ :root { color-scheme: light dark; --bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13); - --acid:#d6ff3d; + --acid:#d6ff3d; --acid-tint: rgb(from var(--acid) r g b / .14); } @media (prefers-color-scheme: light) { - :root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; } + :root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; --acid-tint: rgb(from var(--acid) r g b / .14); } } html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden; user-select: none; } + /* Icons sit in a vertically-centered column: the two spacers split the + remaining space above and below so the stack reads as "quick access" + centered in the strip, Opera-style. */ .strip { display: flex; flex-direction: column; align-items: center; - padding: 10px 0; gap: 4px; height: 100%; box-sizing: border-box; + padding: 6px 0; height: 100%; box-sizing: border-box; border-right: 1px solid var(--line2); } - .icon { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; - cursor: pointer; background: transparent; border: none; padding: 0; color: var(--mut); + .icons { display: flex; flex-direction: column; align-items: center; gap: 4px; + flex: none; } + .spacer { flex: 1 1 auto; min-height: 10px; } + .icon { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; + cursor: pointer; background: transparent; border: none; padding: 0; color: var(--ink); position: relative; } - .icon:hover { background: var(--line2); color: var(--ink); } + .icon:hover { background: var(--line2); } .icon:active { transform: scale(0.94); } - .icon svg { width: 20px; height: 20px; display: block; } - .icon img { width: 22px; height: 22px; display: block; border-radius: 4px; } + .icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); } + .icon svg, .icon img { width: 20px; height: 20px; display: block; } + .icon.letter { font: 600 13px/1 system-ui, sans-serif; color: var(--mut); } .icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px; border: 1px solid var(--line2); white-space: nowrap; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; } .icon:hover .tt { opacity: 1; transition-delay: .4s; } - .sep { width: 24px; height: 1px; background: var(--line2); margin: 6px 0; flex: none; } - .spacer { flex: 1 1 auto; } - /* Add button — small plus to add a custom link. Prompts for URL via main. */ .add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; } .add:hover { color: var(--acid); } -
+
+
+
+
+ +