From 3e5588dd0809fe6844d978644965750d92c8ae1e Mon Sep 17 00:00:00 2001 From: nuelzer0 Date: Sat, 23 May 2026 03:01:48 +0700 Subject: [PATCH] feat: add discord mini tabs extension --- discord-mini-tabs-extension/MANUAL_TESTS.md | 71 + discord-mini-tabs-extension/README.md | 22 + discord-mini-tabs-extension/manifest.json | 16 + discord-mini-tabs-extension/package.json | 12 + .../src/background/service-worker-core.js | 183 ++ .../src/background/service-worker.js | 53 + .../src/background/storage.js | 39 + .../src/background/window-manager.js | 271 ++ .../src/popup/popup.css | 232 ++ .../src/popup/popup.html | 84 + .../src/popup/popup.js | 311 +++ .../src/popup/view-model.js | 30 + .../src/shared/constants.js | 43 + .../src/shared/settings.js | 84 + .../src/shared/shortcuts.js | 105 + discord-mini-tabs-extension/src/shared/url.js | 81 + .../test/popup-controller.test.js | 373 +++ .../test/service-worker-core.test.js | 401 +++ .../test/settings.test.js | 82 + .../test/shortcuts.test.js | 121 + .../test/storage.test.js | 101 + discord-mini-tabs-extension/test/url.test.js | 61 + .../test/view-model.test.js | 77 + .../test/window-manager.test.js | 413 +++ ...cord-mini-tabs-extension-implementation.md | 2358 +++++++++++++++++ ...5-21-discord-mini-tabs-extension-design.md | 256 ++ 26 files changed, 5880 insertions(+) create mode 100644 discord-mini-tabs-extension/MANUAL_TESTS.md create mode 100644 discord-mini-tabs-extension/README.md create mode 100644 discord-mini-tabs-extension/manifest.json create mode 100644 discord-mini-tabs-extension/package.json create mode 100644 discord-mini-tabs-extension/src/background/service-worker-core.js create mode 100644 discord-mini-tabs-extension/src/background/service-worker.js create mode 100644 discord-mini-tabs-extension/src/background/storage.js create mode 100644 discord-mini-tabs-extension/src/background/window-manager.js create mode 100644 discord-mini-tabs-extension/src/popup/popup.css create mode 100644 discord-mini-tabs-extension/src/popup/popup.html create mode 100644 discord-mini-tabs-extension/src/popup/popup.js create mode 100644 discord-mini-tabs-extension/src/popup/view-model.js create mode 100644 discord-mini-tabs-extension/src/shared/constants.js create mode 100644 discord-mini-tabs-extension/src/shared/settings.js create mode 100644 discord-mini-tabs-extension/src/shared/shortcuts.js create mode 100644 discord-mini-tabs-extension/src/shared/url.js create mode 100644 discord-mini-tabs-extension/test/popup-controller.test.js create mode 100644 discord-mini-tabs-extension/test/service-worker-core.test.js create mode 100644 discord-mini-tabs-extension/test/settings.test.js create mode 100644 discord-mini-tabs-extension/test/shortcuts.test.js create mode 100644 discord-mini-tabs-extension/test/storage.test.js create mode 100644 discord-mini-tabs-extension/test/url.test.js create mode 100644 discord-mini-tabs-extension/test/view-model.test.js create mode 100644 discord-mini-tabs-extension/test/window-manager.test.js create mode 100644 docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md create mode 100644 docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md diff --git a/discord-mini-tabs-extension/MANUAL_TESTS.md b/discord-mini-tabs-extension/MANUAL_TESTS.md new file mode 100644 index 000000000..fb207ec27 --- /dev/null +++ b/discord-mini-tabs-extension/MANUAL_TESTS.md @@ -0,0 +1,71 @@ +# Manual Tests + +## Load Unpacked + +1. Open `chrome://extensions`. +2. Enable Developer mode. +3. Load the `C:\Users\RGB\rtk\.worktrees\discord-mini-tabs-extension\discord-mini-tabs-extension` directory. +4. Confirm Chrome shows no manifest or service worker registration errors. + +## Shortcut Management + +1. Add a text shortcut with `https://discord.com/channels/123456789012345678/987654321098765432`. +2. Confirm the shortcut appears in the Text list. +3. Edit the shortcut name. +4. Search for the new name. +5. Delete the shortcut. +6. Confirm the list updates without reopening the popup. + +## Save Current Discord Channel + +1. Open a real Discord channel in a normal Chrome tab. +2. Open the extension popup. +3. Click Save current. +4. Confirm the URL and suggested name populate the form. +5. Save the shortcut. + +## Mini Window + +1. Open a saved text shortcut. +2. Confirm one Chrome popup window opens. +3. Send a Discord chat message in that window. +4. Open another shortcut. +5. Confirm the same popup window is reused. +6. Resize the popup window. +7. Close and reopen a shortcut. +8. Confirm the remembered size is used. + +## Voice URL + +1. Save a Discord voice channel URL as type Voice. +2. Open it from the Voice list. +3. Confirm Discord web displays the voice channel UI. +4. Join or leave voice manually through Discord web. + +## Window Controls + +1. Click Focus and confirm the mini window comes forward. +2. Click Close and confirm the mini window closes. +3. Click Reset position and confirm the next open lets Chrome choose a valid position with the saved size. + +## Settings + +1. Set width to `420`, height to `900`, and zoom to `90`. +2. Open a shortcut. +3. Confirm the mini window size and Discord tab zoom are applied. +4. Change width to `500` and zoom to `100`. +5. Confirm the existing mini window updates. + +## Stability Smoke Test + +1. Switch between several text and voice shortcuts repeatedly. +2. Confirm only one Discord mini window remains open. +3. Close the mini window manually. +4. Open a shortcut again. +5. Confirm the extension recovers without errors. + +## Service Worker Errors + +1. Open `chrome://extensions`. +2. Open the service worker inspection link for Discord Mini Tabs. +3. Confirm there are no uncaught exceptions after opening the popup, adding a shortcut, opening a shortcut, focusing, closing, and changing settings. diff --git a/discord-mini-tabs-extension/README.md b/discord-mini-tabs-extension/README.md new file mode 100644 index 000000000..c9a30f067 --- /dev/null +++ b/discord-mini-tabs-extension/README.md @@ -0,0 +1,22 @@ +# Discord Mini Tabs + +A Chrome Manifest V3 extension that opens saved Discord text and voice channel URLs in one reusable Chrome popup window. + +## Development + +Run pure logic tests: + +```bash +npm test +``` + +Load in Chrome: + +1. Open `chrome://extensions`. +2. Enable Developer mode. +3. Click Load unpacked. +4. Select the `discord-mini-tabs-extension` directory. + +## Scope + +Discord runs as the official Discord web app in a real Chrome popup window. This extension does not embed Discord, inject content scripts, read Discord messages, or inspect Discord voice internals. diff --git a/discord-mini-tabs-extension/manifest.json b/discord-mini-tabs-extension/manifest.json new file mode 100644 index 000000000..b39ad8d3d --- /dev/null +++ b/discord-mini-tabs-extension/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 3, + "name": "Discord Mini Tabs", + "version": "0.1.0", + "description": "Open saved Discord text and voice channels in one reusable mini Chrome popup window.", + "permissions": ["storage", "tabs", "windows", "activeTab"], + "host_permissions": ["https://discord.com/*"], + "background": { + "service_worker": "src/background/service-worker.js", + "type": "module" + }, + "action": { + "default_title": "Discord Mini Tabs", + "default_popup": "src/popup/popup.html" + } +} diff --git a/discord-mini-tabs-extension/package.json b/discord-mini-tabs-extension/package.json new file mode 100644 index 000000000..e70d2a296 --- /dev/null +++ b/discord-mini-tabs-extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "discord-mini-tabs-extension", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} diff --git a/discord-mini-tabs-extension/src/background/service-worker-core.js b/discord-mini-tabs-extension/src/background/service-worker-core.js new file mode 100644 index 000000000..b143047d7 --- /dev/null +++ b/discord-mini-tabs-extension/src/background/service-worker-core.js @@ -0,0 +1,183 @@ +import { MESSAGE_TYPES } from "../shared/constants.js"; + +function getSuggestedName(title) { + const cleanedTitle = String(title ?? "") + .replace(/\s*(?:-|[|])\s*Discord$/i, "") + .trim(); + return cleanedTitle || "Discord channel"; +} + +export function createServiceWorkerController({ + chromeApi, + getExtensionState, + setShortcuts, + setWindowState, + createShortcut, + deleteShortcut, + findShortcut, + updateShortcut, + validateDiscordChannelUrl, + closeMiniWindow, + focusMiniWindow, + openShortcutInMiniWindow, + resetMiniWindowPosition, + saveBoundsFromWindow, + updateMiniWindowSettings, + setTimeoutFn, + clearTimeoutFn, + debounceMs = 400 +}) { + const boundsSaveTimers = new Map(); + const boundsSaveTokens = new Map(); + let shortcutMutationQueue = Promise.resolve(); + + function enqueueShortcutMutation(operation) { + const next = shortcutMutationQueue.catch(() => undefined).then(operation); + shortcutMutationQueue = next.catch(() => undefined); + return next; + } + + async function handleCreateShortcut(payload) { + const state = await getExtensionState(); + const shortcut = createShortcut(payload); + const shortcuts = await setShortcuts([...state.shortcuts, shortcut]); + return { shortcut, shortcuts }; + } + + async function handleUpdateShortcut(payload) { + const state = await getExtensionState(); + const existing = findShortcut(state.shortcuts, payload.id); + if (!existing) { + throw new Error("Shortcut not found."); + } + + const updated = updateShortcut(existing, payload); + const shortcuts = await setShortcuts( + state.shortcuts.map((shortcut) => (shortcut.id === updated.id ? updated : shortcut)) + ); + return { shortcut: updated, shortcuts }; + } + + async function handleDeleteShortcut(payload) { + const state = await getExtensionState(); + const shortcuts = await setShortcuts(deleteShortcut(state.shortcuts, payload.id)); + return { shortcuts }; + } + + async function handleOpenShortcut(payload) { + const state = await getExtensionState(); + const shortcut = findShortcut(state.shortcuts, payload.id); + if (!shortcut) { + throw new Error("Shortcut not found."); + } + + const windowState = await openShortcutInMiniWindow({ chromeApi, shortcut }); + return { windowState }; + } + + async function handleReadActiveDiscordTab() { + const tabs = await chromeApi.tabs.query({ active: true, currentWindow: true }); + const activeTab = tabs[0]; + const result = validateDiscordChannelUrl(activeTab?.url ?? ""); + if (!result.ok) { + throw new Error("The active tab is not a supported Discord channel URL."); + } + + return { + url: result.url, + title: activeTab.title ?? "", + suggestedName: getSuggestedName(activeTab.title) + }; + } + + async function handleMessage(message) { + const payload = message?.payload ?? {}; + + switch (message?.type) { + case MESSAGE_TYPES.GET_STATE: + return getExtensionState(); + case MESSAGE_TYPES.CREATE_SHORTCUT: + return enqueueShortcutMutation(() => handleCreateShortcut(payload)); + case MESSAGE_TYPES.UPDATE_SHORTCUT: + return enqueueShortcutMutation(() => handleUpdateShortcut(payload)); + case MESSAGE_TYPES.DELETE_SHORTCUT: + return enqueueShortcutMutation(() => handleDeleteShortcut(payload)); + case MESSAGE_TYPES.OPEN_SHORTCUT: + return handleOpenShortcut(payload); + case MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB: + return handleReadActiveDiscordTab(); + case MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS: + return { + windowState: await updateMiniWindowSettings({ + chromeApi, + bounds: payload.bounds, + zoom: payload.zoom + }) + }; + case MESSAGE_TYPES.FOCUS_WINDOW: + return { windowId: await focusMiniWindow({ chromeApi }) }; + case MESSAGE_TYPES.CLOSE_WINDOW: + return { windowState: await closeMiniWindow({ chromeApi }) }; + case MESSAGE_TYPES.RESET_POSITION: + return { windowState: await resetMiniWindowPosition({ chromeApi }) }; + default: + throw new Error("Unsupported message type."); + } + } + + async function handleWindowRemoved(windowId) { + try { + const state = await getExtensionState(); + if (state.windowState.windowId === windowId) { + await setWindowState({ ...state.windowState, windowId: null, tabId: null }); + } + } catch { + // Background window events must not surface unhandled storage failures. + } + } + + function handleBoundsChanged(changedWindow) { + if (!changedWindow || changedWindow.type !== "popup" || !Number.isInteger(changedWindow.id)) { + return Promise.resolve(); + } + + const windowId = changedWindow.id; + const token = Symbol("bounds-save"); + boundsSaveTokens.set(windowId, token); + clearTimeoutFn(boundsSaveTimers.get(windowId)); + const timerId = setTimeoutFn(async () => { + if (boundsSaveTimers.get(windowId) === timerId) { + boundsSaveTimers.delete(windowId); + } + if (boundsSaveTokens.get(windowId) !== token) { + return; + } + + try { + const state = await getExtensionState(); + if (boundsSaveTokens.get(windowId) !== token) { + return; + } + + await saveBoundsFromWindow(changedWindow, { + expectedWindowId: state.windowState.windowId, + shouldSave: () => boundsSaveTokens.get(windowId) === token + }); + } catch { + // Background window events must not surface unhandled storage failures. + } finally { + if (boundsSaveTokens.get(windowId) === token) { + boundsSaveTokens.delete(windowId); + } + } + }, debounceMs); + boundsSaveTimers.set(windowId, timerId); + return Promise.resolve(); + } + + return { + handleMessage, + handleWindowRemoved, + handleBoundsChanged + }; +} diff --git a/discord-mini-tabs-extension/src/background/service-worker.js b/discord-mini-tabs-extension/src/background/service-worker.js new file mode 100644 index 000000000..b1e48c5c6 --- /dev/null +++ b/discord-mini-tabs-extension/src/background/service-worker.js @@ -0,0 +1,53 @@ +import { createServiceWorkerController } from "./service-worker-core.js"; +import { createShortcut, deleteShortcut, findShortcut, updateShortcut } from "../shared/shortcuts.js"; +import { validateDiscordChannelUrl } from "../shared/url.js"; +import { getExtensionState, setShortcuts, setWindowState } from "./storage.js"; +import { + closeMiniWindow, + focusMiniWindow, + openShortcutInMiniWindow, + resetMiniWindowPosition, + saveBoundsFromWindow, + updateMiniWindowSettings +} from "./window-manager.js"; + +function errorMessage(error) { + return error instanceof Error ? error.message : "Unknown extension error."; +} + +const controller = createServiceWorkerController({ + chromeApi: chrome, + getExtensionState, + setShortcuts, + setWindowState, + createShortcut, + deleteShortcut, + findShortcut, + updateShortcut, + validateDiscordChannelUrl, + closeMiniWindow, + focusMiniWindow, + openShortcutInMiniWindow, + resetMiniWindowPosition, + saveBoundsFromWindow, + updateMiniWindowSettings, + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout, + debounceMs: 400 +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + controller + .handleMessage(message) + .then((data) => sendResponse({ ok: true, data })) + .catch((error) => sendResponse({ ok: false, error: errorMessage(error) })); + return true; +}); + +chrome.windows.onRemoved.addListener((windowId) => { + controller.handleWindowRemoved(windowId); +}); + +chrome.windows.onBoundsChanged.addListener((changedWindow) => { + controller.handleBoundsChanged(changedWindow); +}); diff --git a/discord-mini-tabs-extension/src/background/storage.js b/discord-mini-tabs-extension/src/background/storage.js new file mode 100644 index 000000000..290c22031 --- /dev/null +++ b/discord-mini-tabs-extension/src/background/storage.js @@ -0,0 +1,39 @@ +import { STORAGE_KEYS } from "../shared/constants.js"; +import { createDefaultWindowState, normalizeWindowState } from "../shared/settings.js"; +import { normalizeShortcutList } from "../shared/shortcuts.js"; + +const DEFAULT_STATE = { + [STORAGE_KEYS.SHORTCUTS]: [], + [STORAGE_KEYS.WINDOW_STATE]: createDefaultWindowState() +}; + +function getDefaultStorageArea() { + return chrome.storage.local; +} + +export async function getExtensionState(storageArea = getDefaultStorageArea()) { + const data = await storageArea.get(DEFAULT_STATE); + return { + shortcuts: normalizeShortcutList(data[STORAGE_KEYS.SHORTCUTS]), + windowState: normalizeWindowState(data[STORAGE_KEYS.WINDOW_STATE]) + }; +} + +export async function setShortcuts(shortcuts, storageArea = getDefaultStorageArea()) { + const normalized = normalizeShortcutList(shortcuts); + await storageArea.set({ [STORAGE_KEYS.SHORTCUTS]: normalized }); + return normalized; +} + +export async function setWindowState(windowState, storageArea = getDefaultStorageArea()) { + const normalized = normalizeWindowState(windowState); + await storageArea.set({ [STORAGE_KEYS.WINDOW_STATE]: normalized }); + return normalized; +} + +export async function updateWindowState(updater, storageArea = getDefaultStorageArea()) { + const state = await getExtensionState(storageArea); + const nextWindowState = normalizeWindowState(updater(state.windowState)); + await setWindowState(nextWindowState, storageArea); + return nextWindowState; +} diff --git a/discord-mini-tabs-extension/src/background/window-manager.js b/discord-mini-tabs-extension/src/background/window-manager.js new file mode 100644 index 000000000..a86e1209d --- /dev/null +++ b/discord-mini-tabs-extension/src/background/window-manager.js @@ -0,0 +1,271 @@ +import { DEFAULT_BOUNDS } from "../shared/constants.js"; +import { clampBounds, normalizeWindowState } from "../shared/settings.js"; +import { getExtensionState, setWindowState, updateWindowState } from "./storage.js"; + +let windowMutationQueue = Promise.resolve(); + +function getDefaultChromeApi() { + if (typeof chrome === "undefined") { + throw new Error("Chrome API is unavailable."); + } + return chrome; +} + +function enqueueWindowMutation(operation) { + const next = windowMutationQueue.catch(() => undefined).then(operation); + windowMutationQueue = next.catch(() => undefined); + return next; +} + +function boundsToCreateData(bounds, url) { + const createData = { + url, + type: "popup", + focused: true, + width: bounds.width, + height: bounds.height + }; + + if (Number.isInteger(bounds.left)) createData.left = bounds.left; + if (Number.isInteger(bounds.top)) createData.top = bounds.top; + + return createData; +} + +async function getWindowWithTab(chromeApi, windowState) { + if (!Number.isInteger(windowState.windowId)) { + return null; + } + + try { + const currentWindow = await chromeApi.windows.get(windowState.windowId, { populate: true }); + if (currentWindow.type !== "popup") { + return null; + } + + const existingTab = + currentWindow.tabs?.find((tab) => tab.id === windowState.tabId) ?? + currentWindow.tabs?.[0] ?? + null; + if (!Number.isInteger(existingTab?.id)) { + return null; + } + + return { window: currentWindow, tab: existingTab }; + } catch { + return null; + } +} + +async function applyZoom(chromeApi, tabId, zoom) { + try { + await chromeApi.tabs.setZoom(tabId, zoom); + return true; + } catch { + return false; + } +} + +export async function openShortcutInMiniWindow({ + chromeApi = getDefaultChromeApi(), + storageArea, + shortcut +}) { + return enqueueWindowMutation(() => + openShortcutInMiniWindowUnlocked({ chromeApi, storageArea, shortcut }) + ); +} + +async function openShortcutInMiniWindowUnlocked({ chromeApi, storageArea, shortcut }) { + const state = await getExtensionState(storageArea); + const windowState = normalizeWindowState(state.windowState); + const existing = await getWindowWithTab(chromeApi, windowState); + + if (existing) { + await chromeApi.windows.update(existing.window.id, { focused: true }); + const tab = await chromeApi.tabs.update(existing.tab.id, { + url: shortcut.url, + active: true + }); + const tabId = Number.isInteger(tab?.id) ? tab.id : existing.tab.id; + await applyZoom(chromeApi, tabId, windowState.zoom); + + return setWindowState( + { + ...windowState, + windowId: existing.window.id, + tabId, + lastShortcutId: shortcut.id + }, + storageArea + ); + } + + const bounds = clampBounds(windowState.bounds ?? DEFAULT_BOUNDS); + const createdWindow = await chromeApi.windows.create(boundsToCreateData(bounds, shortcut.url)); + const createdTab = createdWindow?.tabs?.[0] ?? null; + if (!Number.isInteger(createdWindow?.id) || !Number.isInteger(createdTab?.id)) { + throw new Error("Chrome did not return a usable Discord mini window."); + } + + await applyZoom(chromeApi, createdTab.id, windowState.zoom); + + return setWindowState( + { + ...windowState, + windowId: createdWindow.id, + tabId: createdTab.id, + bounds, + lastShortcutId: shortcut.id + }, + storageArea + ); +} + +export async function focusMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) { + return enqueueWindowMutation(() => focusMiniWindowUnlocked({ chromeApi, storageArea })); +} + +async function focusMiniWindowUnlocked({ chromeApi, storageArea }) { + const state = await getExtensionState(storageArea); + const existing = await getWindowWithTab(chromeApi, state.windowState); + if (!existing) { + await setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea); + return null; + } + + await chromeApi.windows.update(existing.window.id, { focused: true }); + return existing.window.id; +} + +export async function closeMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) { + return enqueueWindowMutation(() => closeMiniWindowUnlocked({ chromeApi, storageArea })); +} + +async function closeMiniWindowUnlocked({ chromeApi, storageArea }) { + const state = await getExtensionState(storageArea); + if (Number.isInteger(state.windowState.windowId)) { + try { + await chromeApi.windows.remove(state.windowState.windowId); + } catch { + // The window may already be closed. Clearing stored ids is still correct. + } + } + + return setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea); +} + +export async function resetMiniWindowPosition({ + chromeApi = getDefaultChromeApi(), + storageArea +}) { + return enqueueWindowMutation(() => + resetMiniWindowPositionUnlocked({ chromeApi, storageArea }) + ); +} + +async function resetMiniWindowPositionUnlocked({ chromeApi, storageArea }) { + const state = await updateWindowState( + (windowState) => ({ + ...windowState, + bounds: { ...windowState.bounds, left: null, top: null } + }), + storageArea + ); + + if (Number.isInteger(state.windowId)) { + try { + await chromeApi.windows.update(state.windowId, { + width: state.bounds.width, + height: state.bounds.height + }); + } catch { + return setWindowState({ ...state, windowId: null, tabId: null }, storageArea); + } + } + + return state; +} + +export async function updateMiniWindowSettings({ + chromeApi = getDefaultChromeApi(), + storageArea, + bounds, + zoom +}) { + return enqueueWindowMutation(() => + updateMiniWindowSettingsUnlocked({ chromeApi, storageArea, bounds, zoom }) + ); +} + +async function updateMiniWindowSettingsUnlocked({ chromeApi, storageArea, bounds, zoom }) { + const state = await updateWindowState( + (windowState) => ({ + ...windowState, + bounds: clampBounds({ ...windowState.bounds, ...bounds }), + zoom: zoom ?? windowState.zoom + }), + storageArea + ); + + if (Number.isInteger(state.windowId)) { + try { + const updateInfo = { + width: state.bounds.width, + height: state.bounds.height + }; + if (Number.isInteger(state.bounds.left)) updateInfo.left = state.bounds.left; + if (Number.isInteger(state.bounds.top)) updateInfo.top = state.bounds.top; + + await chromeApi.windows.update(state.windowId, updateInfo); + if (Number.isInteger(state.tabId)) { + await applyZoom(chromeApi, state.tabId, state.zoom); + } + } catch { + return setWindowState({ ...state, windowId: null, tabId: null }, storageArea); + } + } + + return state; +} + +export async function saveBoundsFromWindow( + window, + { storageArea, expectedWindowId, shouldSave = () => true } +) { + return enqueueWindowMutation(() => + saveBoundsFromWindowUnlocked(window, { storageArea, expectedWindowId, shouldSave }) + ); +} + +async function saveBoundsFromWindowUnlocked( + window, + { storageArea, expectedWindowId, shouldSave = () => true } +) { + if (!window || window.type !== "popup" || window.id !== expectedWindowId) { + return null; + } + + const state = await getExtensionState(storageArea); + const windowState = normalizeWindowState(state.windowState); + if (windowState.windowId !== window.id) { + return null; + } + + if (!(await shouldSave())) { + return null; + } + + return setWindowState( + { + ...windowState, + bounds: clampBounds({ + left: window.left, + top: window.top, + width: window.width, + height: window.height + }) + }, + storageArea + ); +} diff --git a/discord-mini-tabs-extension/src/popup/popup.css b/discord-mini-tabs-extension/src/popup/popup.css new file mode 100644 index 000000000..2770e369a --- /dev/null +++ b/discord-mini-tabs-extension/src/popup/popup.css @@ -0,0 +1,232 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #121318; + color: #f3f4f8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + width: 380px; + min-height: 560px; + background: #121318; + color: #f3f4f8; +} + +button, +input, +select { + font: inherit; +} + +button { + min-height: 34px; + border: 1px solid #363946; + border-radius: 6px; + background: #20222b; + color: #f3f4f8; + cursor: pointer; + white-space: nowrap; +} + +button:hover { + border-color: #5865f2; +} + +button.primary { + border-color: #5865f2; + background: #5865f2; + color: #ffffff; + font-weight: 700; +} + +input, +select { + width: 100%; + min-width: 0; + min-height: 34px; + border: 1px solid #363946; + border-radius: 6px; + padding: 7px 9px; + background: #171922; + color: #f3f4f8; +} + +input::placeholder { + color: #858b9b; +} + +label { + display: grid; + gap: 5px; + min-width: 0; + color: #c4c8d4; + font-size: 12px; + font-weight: 700; +} + +.shell { + display: grid; + gap: 10px; + padding: 12px; +} + +.topbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +h1 { + margin: 0; + font-size: 17px; + line-height: 1.2; +} + +p { + margin: 0; +} + +#windowMeta { + margin-top: 3px; + color: #aeb3c2; + font-size: 12px; +} + +.feedback { + border: 1px solid #6b3a3a; + border-radius: 6px; + padding: 8px 10px; + background: #2a171b; + color: #ffb8bd; + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.feedback[hidden] { + display: none; +} + +.panel { + border: 1px solid #2d303a; + border-radius: 8px; + background: #191b23; + padding: 10px; +} + +.controls { + display: grid; + grid-template-columns: 1fr 1fr 1.35fr; + gap: 8px; +} + +.shortcuts { + display: grid; + gap: 9px; +} + +.segmented { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.segmented button { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.segmented button.active { + border-color: #5865f2; + background: #2f344f; +} + +.segmented span { + min-width: 20px; + border-radius: 999px; + padding: 1px 6px; + background: #11131a; + color: #d9dcff; + font-size: 11px; +} + +.shortcut-list { + display: grid; + gap: 8px; + min-height: 58px; + max-height: 190px; + overflow-y: auto; +} + +.shortcut-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + border: 1px solid #30333e; + border-radius: 8px; + padding: 9px; + background: #20222b; +} + +.shortcut-name { + color: #ffffff; + font-size: 13px; + font-weight: 800; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.shortcut-url { + margin-top: 4px; + color: #aeb3c2; + font-size: 12px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.shortcut-actions { + display: grid; + grid-template-columns: repeat(3, 32px); + gap: 5px; +} + +.shortcut-actions button { + width: 32px; + min-height: 30px; + padding: 0; +} + +.empty { + display: grid; + min-height: 58px; + place-items: center; + color: #858b9b; + font-size: 12px; +} + +.form { + display: grid; + gap: 9px; +} + +.form-actions { + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: 8px; +} + +.settings { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + gap: 8px; + align-items: end; +} diff --git a/discord-mini-tabs-extension/src/popup/popup.html b/discord-mini-tabs-extension/src/popup/popup.html new file mode 100644 index 000000000..b9597897e --- /dev/null +++ b/discord-mini-tabs-extension/src/popup/popup.html @@ -0,0 +1,84 @@ + + + + + + Discord Mini Tabs + + + +
+
+
+

Discord Mini Tabs

+

Closed | 420 x 900 | 90%

+
+ +
+ + + +
+ + + +
+ +
+ + +
+ + +
+ +
+
+ +
+

Add shortcut

+ + + + +
+ + +
+
+ +
+ + + + +
+
+ + + diff --git a/discord-mini-tabs-extension/src/popup/popup.js b/discord-mini-tabs-extension/src/popup/popup.js new file mode 100644 index 000000000..5307a9678 --- /dev/null +++ b/discord-mini-tabs-extension/src/popup/popup.js @@ -0,0 +1,311 @@ +import { + DEFAULT_BOUNDS, + DEFAULT_ZOOM, + MESSAGE_TYPES, + SHORTCUT_TYPES +} from "../shared/constants.js"; +import { compactDiscordUrl } from "../shared/url.js"; +import { buildPopupModel } from "./view-model.js"; + +const CONTROL_IDS = { + windowMeta: "windowMeta", + saveCurrentButton: "saveCurrentButton", + feedback: "feedback", + focusButton: "focusButton", + closeButton: "closeButton", + resetButton: "resetButton", + searchInput: "searchInput", + textTab: "textTab", + voiceTab: "voiceTab", + textCount: "textCount", + voiceCount: "voiceCount", + shortcutList: "shortcutList", + shortcutForm: "shortcutForm", + formTitle: "formTitle", + editingId: "editingId", + shortcutName: "shortcutName", + shortcutUrl: "shortcutUrl", + shortcutType: "shortcutType", + cancelEditButton: "cancelEditButton", + settingsForm: "settingsForm", + widthInput: "widthInput", + heightInput: "heightInput", + zoomInput: "zoomInput" +}; + +function toNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toZoomPercent(zoom) { + return Math.round(Number(zoom ?? DEFAULT_ZOOM) * 100); +} + +function getSafeWindowState(windowState) { + const bounds = windowState?.bounds ?? {}; + const width = Number(bounds.width); + const height = Number(bounds.height); + return { + ...windowState, + bounds: { + ...DEFAULT_BOUNDS, + ...bounds, + width: Number.isFinite(width) ? width : DEFAULT_BOUNDS.width, + height: Number.isFinite(height) ? height : DEFAULT_BOUNDS.height + }, + zoom: Number.isFinite(Number(windowState?.zoom)) ? Number(windowState.zoom) : DEFAULT_ZOOM + }; +} + +function getRequiredElement(documentRef, id) { + const element = documentRef.getElementById(id); + if (!element) { + throw new Error(`Missing popup element: ${id}`); + } + return element; +} + +export function createPopupApp({ document, chromeApi }) { + const elements = Object.fromEntries( + Object.entries(CONTROL_IDS).map(([key, id]) => [key, getRequiredElement(document, id)]) + ); + const state = { + shortcuts: [], + windowState: getSafeWindowState(null), + query: "", + activeType: SHORTCUT_TYPES.TEXT + }; + + async function sendMessage(type, payload = {}) { + const response = await chromeApi.runtime.sendMessage({ type, payload }); + if (!response?.ok) { + throw new Error(response?.error || "Popup request failed."); + } + return response.data; + } + + function showError(error) { + elements.feedback.textContent = error?.message || String(error || "Something went wrong."); + elements.feedback.hidden = false; + } + + function clearFeedback() { + elements.feedback.textContent = ""; + elements.feedback.hidden = true; + } + + function setText(element, value) { + element.textContent = String(value); + } + + function setActiveType(type) { + state.activeType = type === SHORTCUT_TYPES.VOICE ? SHORTCUT_TYPES.VOICE : SHORTCUT_TYPES.TEXT; + if (!elements.editingId.value.trim()) { + elements.shortcutType.value = state.activeType; + } + render(); + } + + function clearShortcutForm() { + elements.formTitle.textContent = "Add shortcut"; + elements.editingId.value = ""; + elements.shortcutName.value = ""; + elements.shortcutUrl.value = ""; + elements.shortcutType.value = state.activeType; + } + + function populateShortcutForm(shortcut) { + elements.formTitle.textContent = "Edit shortcut"; + elements.editingId.value = shortcut.id; + elements.shortcutName.value = shortcut.name; + elements.shortcutUrl.value = shortcut.url; + elements.shortcutType.value = shortcut.type; + } + + function createButton(label, title, handler) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.setAttribute("title", title); + button.addEventListener("click", handler); + return button; + } + + function renderShortcut(shortcut) { + const card = document.createElement("article"); + card.className = "shortcut-card"; + + const content = document.createElement("div"); + const name = document.createElement("div"); + name.className = "shortcut-name"; + name.textContent = shortcut.name; + + const url = document.createElement("div"); + url.className = "shortcut-url"; + url.textContent = compactDiscordUrl(shortcut.url) || shortcut.url; + + content.append(name, url); + + const actions = document.createElement("div"); + actions.className = "shortcut-actions"; + actions.append( + createButton("O", "Open", () => runAction(async () => { + await sendMessage(MESSAGE_TYPES.OPEN_SHORTCUT, { id: shortcut.id }); + await refresh(); + })), + createButton("E", "Edit", () => { + clearFeedback(); + populateShortcutForm(shortcut); + }), + createButton("D", "Delete", () => runAction(async () => { + await sendMessage(MESSAGE_TYPES.DELETE_SHORTCUT, { id: shortcut.id }); + await refresh(); + })) + ); + + card.append(content, actions); + return card; + } + + function renderEmpty() { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "No shortcuts"; + return empty; + } + + function render() { + const model = buildPopupModel({ + shortcuts: state.shortcuts, + query: state.query, + activeType: state.activeType, + windowState: state.windowState + }); + + setText(elements.windowMeta, `${model.status} | ${model.boundsLabel} | ${model.zoomLabel}`); + setText(elements.textCount, model.textCount); + setText(elements.voiceCount, model.voiceCount); + elements.textTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.TEXT); + elements.voiceTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.VOICE); + const shortcutNodes = model.activeShortcuts.map(renderShortcut); + elements.shortcutList.replaceChildren(...(shortcutNodes.length ? shortcutNodes : [renderEmpty()])); + } + + function renderSettingsInputs() { + const bounds = state.windowState.bounds; + elements.widthInput.value = String(bounds.width); + elements.heightInput.value = String(bounds.height); + elements.zoomInput.value = String(toZoomPercent(state.windowState.zoom)); + } + + async function refresh() { + const data = await sendMessage(MESSAGE_TYPES.GET_STATE); + state.shortcuts = Array.isArray(data?.shortcuts) ? data.shortcuts : []; + state.windowState = getSafeWindowState(data?.windowState); + renderSettingsInputs(); + render(); + } + + async function runAction(action) { + try { + clearFeedback(); + await action(); + } catch (error) { + showError(error); + } + } + + function bindEvents() { + elements.searchInput.addEventListener("input", () => { + state.query = elements.searchInput.value; + render(); + }); + elements.textTab.addEventListener("click", () => setActiveType(SHORTCUT_TYPES.TEXT)); + elements.voiceTab.addEventListener("click", () => setActiveType(SHORTCUT_TYPES.VOICE)); + elements.cancelEditButton.addEventListener("click", () => { + clearFeedback(); + clearShortcutForm(); + }); + + elements.focusButton.addEventListener("click", () => runAction(async () => { + await sendMessage(MESSAGE_TYPES.FOCUS_WINDOW); + await refresh(); + })); + elements.closeButton.addEventListener("click", () => runAction(async () => { + await sendMessage(MESSAGE_TYPES.CLOSE_WINDOW); + await refresh(); + })); + elements.resetButton.addEventListener("click", () => runAction(async () => { + await sendMessage(MESSAGE_TYPES.RESET_POSITION); + await refresh(); + })); + + elements.saveCurrentButton.addEventListener("click", () => runAction(async () => { + const data = await sendMessage(MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB); + clearShortcutForm(); + elements.shortcutName.value = data?.suggestedName ?? ""; + elements.shortcutUrl.value = data?.url ?? ""; + elements.shortcutType.value = state.activeType; + })); + + elements.shortcutForm.addEventListener("submit", (event) => runAction(async () => { + event.preventDefault(); + const id = elements.editingId.value.trim(); + const payload = { + name: elements.shortcutName.value.trim(), + url: elements.shortcutUrl.value.trim(), + type: elements.shortcutType.value === SHORTCUT_TYPES.VOICE + ? SHORTCUT_TYPES.VOICE + : SHORTCUT_TYPES.TEXT + }; + if (id) { + await sendMessage(MESSAGE_TYPES.UPDATE_SHORTCUT, { id, ...payload }); + } else { + await sendMessage(MESSAGE_TYPES.CREATE_SHORTCUT, payload); + } + state.activeType = payload.type; + clearShortcutForm(); + await refresh(); + })); + + elements.settingsForm.addEventListener("submit", (event) => runAction(async () => { + event.preventDefault(); + const payload = { + bounds: { + width: toNumber(elements.widthInput.value, DEFAULT_BOUNDS.width), + height: toNumber(elements.heightInput.value, DEFAULT_BOUNDS.height) + }, + zoom: toNumber(elements.zoomInput.value, toZoomPercent(DEFAULT_ZOOM)) / 100 + }; + await sendMessage(MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS, payload); + await refresh(); + })); + } + + async function init() { + bindEvents(); + clearShortcutForm(); + await runAction(refresh); + } + + return { + init, + refresh, + sendMessage + }; +} + +if (globalThis.document && globalThis.chrome?.runtime?.sendMessage) { + const app = createPopupApp({ + document: globalThis.document, + chromeApi: globalThis.chrome + }); + if (globalThis.document.readyState === "loading") { + globalThis.document.addEventListener("DOMContentLoaded", () => { + app.init(); + }); + } else { + app.init(); + } +} diff --git a/discord-mini-tabs-extension/src/popup/view-model.js b/discord-mini-tabs-extension/src/popup/view-model.js new file mode 100644 index 000000000..507f6c273 --- /dev/null +++ b/discord-mini-tabs-extension/src/popup/view-model.js @@ -0,0 +1,30 @@ +import { DEFAULT_BOUNDS, DEFAULT_ZOOM, SHORTCUT_TYPES } from "../shared/constants.js"; +import { filterShortcuts, splitShortcutsByType } from "../shared/shortcuts.js"; + +export function formatWindowStatus(windowState) { + return Number.isInteger(windowState?.windowId) ? "Open" : "Closed"; +} + +export function formatZoomPercent(zoom) { + return `${Math.round(Number(zoom) * 100)}%`; +} + +export function buildPopupModel({ shortcuts, query, activeType, windowState }) { + const filteredShortcuts = filterShortcuts(shortcuts, query); + const groupedShortcuts = splitShortcutsByType(filteredShortcuts); + const normalizedActiveType = + activeType === SHORTCUT_TYPES.VOICE ? SHORTCUT_TYPES.VOICE : SHORTCUT_TYPES.TEXT; + const bounds = windowState?.bounds ?? {}; + const width = bounds.width ?? DEFAULT_BOUNDS.width; + const height = bounds.height ?? DEFAULT_BOUNDS.height; + + return { + status: formatWindowStatus(windowState), + zoomLabel: formatZoomPercent(windowState?.zoom ?? DEFAULT_ZOOM), + boundsLabel: `${width} x ${height}`, + activeType: normalizedActiveType, + activeShortcuts: groupedShortcuts[normalizedActiveType], + textCount: groupedShortcuts.text.length, + voiceCount: groupedShortcuts.voice.length + }; +} diff --git a/discord-mini-tabs-extension/src/shared/constants.js b/discord-mini-tabs-extension/src/shared/constants.js new file mode 100644 index 000000000..3a3e87585 --- /dev/null +++ b/discord-mini-tabs-extension/src/shared/constants.js @@ -0,0 +1,43 @@ +export const STORAGE_KEYS = { + SHORTCUTS: "shortcuts", + WINDOW_STATE: "windowState" +}; + +export const SHORTCUT_TYPES = { + TEXT: "text", + VOICE: "voice" +}; + +export const DEFAULT_BOUNDS = { + left: null, + top: null, + width: 420, + height: 900 +}; + +export const BOUNDS_LIMITS = { + minWidth: 320, + minHeight: 480, + maxWidth: 1200, + maxHeight: 1400 +}; + +export const DEFAULT_ZOOM = 0.9; +export const MIN_ZOOM = 0.67; +export const MAX_ZOOM = 1.25; + +export const DISCORD_HOST = "discord.com"; +export const DISCORD_CHANNEL_PREFIX = "/channels/"; + +export const MESSAGE_TYPES = { + GET_STATE: "GET_STATE", + CREATE_SHORTCUT: "CREATE_SHORTCUT", + UPDATE_SHORTCUT: "UPDATE_SHORTCUT", + DELETE_SHORTCUT: "DELETE_SHORTCUT", + OPEN_SHORTCUT: "OPEN_SHORTCUT", + READ_ACTIVE_DISCORD_TAB: "READ_ACTIVE_DISCORD_TAB", + UPDATE_WINDOW_SETTINGS: "UPDATE_WINDOW_SETTINGS", + FOCUS_WINDOW: "FOCUS_WINDOW", + CLOSE_WINDOW: "CLOSE_WINDOW", + RESET_POSITION: "RESET_POSITION" +}; diff --git a/discord-mini-tabs-extension/src/shared/settings.js b/discord-mini-tabs-extension/src/shared/settings.js new file mode 100644 index 000000000..4ec7c98e0 --- /dev/null +++ b/discord-mini-tabs-extension/src/shared/settings.js @@ -0,0 +1,84 @@ +import { + BOUNDS_LIMITS, + DEFAULT_BOUNDS, + DEFAULT_ZOOM, + MAX_ZOOM, + MIN_ZOOM +} from "./constants.js"; + +function clampNumber(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.round(number))); +} + +function normalizeNullableCoordinate(value) { + if (value === null || value === undefined) { + return null; + } + + const number = Number(value); + return Number.isFinite(number) ? Math.round(number) : null; +} + +function normalizeObject(value) { + return value && typeof value === "object" ? value : {}; +} + +export function clampBounds(bounds = {}) { + const source = normalizeObject(bounds); + + return { + left: normalizeNullableCoordinate(source.left), + top: normalizeNullableCoordinate(source.top), + width: clampNumber( + source.width, + BOUNDS_LIMITS.minWidth, + BOUNDS_LIMITS.maxWidth, + DEFAULT_BOUNDS.width + ), + height: clampNumber( + source.height, + BOUNDS_LIMITS.minHeight, + BOUNDS_LIMITS.maxHeight, + DEFAULT_BOUNDS.height + ) + }; +} + +export function clampZoom(value) { + const number = Number(value); + if (!Number.isFinite(number)) { + return DEFAULT_ZOOM; + } + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(number.toFixed(2)))); +} + +export function createDefaultWindowState() { + return { + windowId: null, + tabId: null, + bounds: { ...DEFAULT_BOUNDS }, + zoom: DEFAULT_ZOOM, + lastShortcutId: null + }; +} + +export function normalizeWindowState(input = {}) { + const defaults = createDefaultWindowState(); + const source = normalizeObject(input); + const bounds = normalizeObject(source.bounds); + + return { + windowId: Number.isInteger(source.windowId) ? source.windowId : null, + tabId: Number.isInteger(source.tabId) ? source.tabId : null, + bounds: clampBounds({ ...defaults.bounds, ...bounds }), + zoom: clampZoom(source.zoom ?? defaults.zoom), + lastShortcutId: + typeof source.lastShortcutId === "string" && source.lastShortcutId.length > 0 + ? source.lastShortcutId + : null + }; +} diff --git a/discord-mini-tabs-extension/src/shared/shortcuts.js b/discord-mini-tabs-extension/src/shared/shortcuts.js new file mode 100644 index 000000000..95da41157 --- /dev/null +++ b/discord-mini-tabs-extension/src/shared/shortcuts.js @@ -0,0 +1,105 @@ +import { SHORTCUT_TYPES } from "./constants.js"; +import { normalizeDiscordChannelUrl, validateDiscordChannelUrl } from "./url.js"; + +const VALID_TYPES = new Set([SHORTCUT_TYPES.TEXT, SHORTCUT_TYPES.VOICE]); + +function defaultIdFactory() { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function defaultNow() { + return new Date().toISOString(); +} + +function normalizeName(name) { + const value = String(name ?? "").trim(); + if (value.length === 0) { + throw new Error("Shortcut name is required."); + } + return value; +} + +function normalizeType(type) { + if (!VALID_TYPES.has(type)) { + throw new Error("Shortcut type must be text or voice."); + } + return type; +} + +function isShortcutLike(shortcut) { + return ( + shortcut !== null && + typeof shortcut === "object" && + typeof shortcut.id === "string" && + typeof shortcut.name === "string" && + typeof shortcut.type === "string" && + typeof shortcut.url === "string" && + VALID_TYPES.has(shortcut.type) && + validateDiscordChannelUrl(shortcut.url).ok + ); +} + +export function normalizeShortcutList(shortcuts) { + if (!Array.isArray(shortcuts)) { + return []; + } + return shortcuts.filter(isShortcutLike); +} + +export function createShortcut(input, options = {}) { + const idFactory = options.idFactory ?? defaultIdFactory; + const now = options.now ?? defaultNow; + const timestamp = now(); + + return { + id: idFactory(), + name: normalizeName(input.name), + type: normalizeType(input.type), + url: normalizeDiscordChannelUrl(input.url), + createdAt: timestamp, + updatedAt: timestamp + }; +} + +export function updateShortcut(existing, input) { + const now = input.now ?? defaultNow; + return { + ...existing, + name: normalizeName(input.name ?? existing.name), + type: normalizeType(input.type ?? existing.type), + url: normalizeDiscordChannelUrl(input.url ?? existing.url), + updatedAt: now() + }; +} + +export function deleteShortcut(shortcuts, id) { + return normalizeShortcutList(shortcuts).filter((shortcut) => shortcut.id !== id); +} + +export function findShortcut(shortcuts, id) { + return normalizeShortcutList(shortcuts).find((shortcut) => shortcut.id === id) ?? null; +} + +export function filterShortcuts(shortcuts, query) { + const normalizedShortcuts = normalizeShortcutList(shortcuts); + const normalizedQuery = String(query ?? "").trim().toLowerCase(); + if (!normalizedQuery) { + return normalizedShortcuts; + } + + return normalizedShortcuts.filter((shortcut) => { + const haystack = `${shortcut.name} ${shortcut.url}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }); +} + +export function splitShortcutsByType(shortcuts) { + const normalizedShortcuts = normalizeShortcutList(shortcuts); + return { + text: normalizedShortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.TEXT), + voice: normalizedShortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.VOICE) + }; +} diff --git a/discord-mini-tabs-extension/src/shared/url.js b/discord-mini-tabs-extension/src/shared/url.js new file mode 100644 index 000000000..777d90518 --- /dev/null +++ b/discord-mini-tabs-extension/src/shared/url.js @@ -0,0 +1,81 @@ +import { DISCORD_CHANNEL_PREFIX, DISCORD_HOST } from "./constants.js"; + +function parseUrl(input) { + if (typeof input !== "string" || input.trim().length === 0) { + return null; + } + + try { + return new URL(input.trim()); + } catch { + return null; + } +} + +function isSnowflake(value) { + return /^\d+$/.test(value); +} + +function getChannelParts(url) { + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length !== 3 || parts[0] !== "channels") { + return null; + } + + const [, guildOrMe, channelId] = parts; + if (!guildOrMe || !channelId) { + return null; + } + + if (!isSnowflake(channelId) || (guildOrMe !== "@me" && !isSnowflake(guildOrMe))) { + return null; + } + + return { guildOrMe, channelId }; +} + +export function validateDiscordChannelUrl(input) { + const url = parseUrl(input); + if (!url) { + return { ok: false, error: "Enter a valid Discord channel URL." }; + } + + if (url.protocol !== "https:" || url.hostname !== DISCORD_HOST) { + return { ok: false, error: "Only https://discord.com channel URLs are supported." }; + } + + if (!url.pathname.startsWith(DISCORD_CHANNEL_PREFIX)) { + return { ok: false, error: "The URL must point to discord.com/channels/..." }; + } + + const parts = getChannelParts(url); + if (!parts) { + return { ok: false, error: "The Discord URL must include a server or DM id and channel id." }; + } + + const normalized = `https://${DISCORD_HOST}/channels/${parts.guildOrMe}/${parts.channelId}`; + return { + ok: true, + url: normalized, + guildOrMe: parts.guildOrMe, + channelId: parts.channelId, + scope: parts.guildOrMe === "@me" ? "dm" : "server" + }; +} + +export function normalizeDiscordChannelUrl(input) { + const result = validateDiscordChannelUrl(input); + if (!result.ok) { + throw new Error(result.error); + } + return result.url; +} + +export function compactDiscordUrl(input) { + const result = validateDiscordChannelUrl(input); + if (!result.ok) { + return ""; + } + const guildLabel = result.guildOrMe === "@me" ? "DM" : result.guildOrMe; + return `${guildLabel} / ${result.channelId}`; +} diff --git a/discord-mini-tabs-extension/test/popup-controller.test.js b/discord-mini-tabs-extension/test/popup-controller.test.js new file mode 100644 index 000000000..20f7d6b66 --- /dev/null +++ b/discord-mini-tabs-extension/test/popup-controller.test.js @@ -0,0 +1,373 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createPopupApp } from "../src/popup/popup.js"; +import { MESSAGE_TYPES, SHORTCUT_TYPES } from "../src/shared/constants.js"; + +const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432"; +const voiceUrl = "https://discord.com/channels/223456789012345678/887654321098765432"; + +class FakeClassList { + constructor() { + this.values = new Set(); + } + + add(value) { + this.values.add(value); + } + + remove(value) { + this.values.delete(value); + } + + toggle(value, force) { + if (force) { + this.add(value); + } else { + this.remove(value); + } + } + + contains(value) { + return this.values.has(value); + } +} + +class FakeElement { + constructor(tagName = "div") { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.listeners = new Map(); + this.classList = new FakeClassList(); + this.dataset = {}; + this.attributes = new Map(); + this.hidden = false; + this.value = ""; + this.type = ""; + this.id = ""; + this._textContent = ""; + } + + get textContent() { + return `${this._textContent}${this.children.map((child) => child.textContent).join("")}`; + } + + set textContent(value) { + this._textContent = String(value ?? ""); + this.children = []; + } + + append(...children) { + this.children.push(...children); + } + + replaceChildren(...children) { + this._textContent = ""; + this.children = children; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + addEventListener(type, handler) { + const handlers = this.listeners.get(type) ?? []; + handlers.push(handler); + this.listeners.set(type, handlers); + } + + async dispatchEvent(type) { + const event = { preventDefault() {}, target: this }; + const handlers = this.listeners.get(type) ?? []; + await Promise.all(handlers.map((handler) => handler(event))); + } + + findByText(text) { + if (this._textContent === text) { + return this; + } + for (const child of this.children) { + const match = child.findByText(text); + if (match) { + return match; + } + } + return null; + } +} + +class FakeDocument { + constructor(ids) { + this.elements = new Map(ids.map((id) => [id, new FakeElement()])); + for (const [id, element] of this.elements) { + element.id = id; + } + } + + getElementById(id) { + return this.elements.get(id) ?? null; + } + + createElement(tagName) { + return new FakeElement(tagName); + } +} + +function createPopupDocument() { + return new FakeDocument([ + "windowMeta", + "saveCurrentButton", + "feedback", + "focusButton", + "closeButton", + "resetButton", + "searchInput", + "textTab", + "voiceTab", + "textCount", + "voiceCount", + "shortcutList", + "shortcutForm", + "formTitle", + "editingId", + "shortcutName", + "shortcutUrl", + "shortcutType", + "cancelEditButton", + "settingsForm", + "widthInput", + "heightInput", + "zoomInput" + ]); +} + +function createChromeApi(respond) { + const messages = []; + return { + messages, + runtime: { + async sendMessage(message) { + messages.push(message); + return respond(message); + } + } + }; +} + +test("popup markup exposes resetButton id contract", async () => { + const html = await readFile(new URL("../src/popup/popup.html", import.meta.url), "utf8"); + + assert.match(html, /id="resetButton"/); + assert.match(html, /id="formTitle"/); + assert.doesNotMatch(html, /resetPositionButton/); +}); + +test("initializes from service worker state and renders popup controls", async () => { + const document = createPopupDocument(); + const chromeApi = createChromeApi(async () => ({ + ok: true, + data: { + shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }], + windowState: { + windowId: 12, + bounds: { width: 420, height: 900 }, + zoom: 0.9 + } + } + })); + + const app = createPopupApp({ document, chromeApi }); + await app.init(); + + assert.deepEqual(chromeApi.messages, [{ type: MESSAGE_TYPES.GET_STATE, payload: {} }]); + assert.equal(document.getElementById("windowMeta").textContent, "Open | 420 x 900 | 90%"); + assert.equal(document.getElementById("textCount").textContent, "1"); + assert.equal(document.getElementById("voiceCount").textContent, "0"); + assert.equal(document.getElementById("formTitle").textContent, "Add shortcut"); + assert.equal(document.getElementById("widthInput").value, "420"); + assert.equal(document.getElementById("heightInput").value, "900"); + assert.equal(document.getElementById("zoomInput").value, "90"); + assert.match(document.getElementById("shortcutList").textContent, /Dev Chat/); + assert.match(document.getElementById("shortcutList").textContent, /123456789012345678 \/ 987654321098765432/); +}); + +test("submits exponent-style window settings as bounds and decimal zoom then refreshes", async () => { + const document = createPopupDocument(); + const chromeApi = createChromeApi(async (message) => { + if (message.type === MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS) { + return { ok: true, data: null }; + } + return { + ok: true, + data: { + shortcuts: [], + windowState: { + windowId: null, + bounds: { width: 420, height: 900 }, + zoom: 0.9 + } + } + }; + }); + + const app = createPopupApp({ document, chromeApi }); + await app.init(); + + document.getElementById("widthInput").value = "5e2"; + document.getElementById("heightInput").value = "8e2"; + document.getElementById("zoomInput").value = "1e2"; + await document.getElementById("settingsForm").dispatchEvent("submit"); + + assert.deepEqual(chromeApi.messages, [ + { type: MESSAGE_TYPES.GET_STATE, payload: {} }, + { + type: MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS, + payload: { bounds: { width: 500, height: 800 }, zoom: 1 } + }, + { type: MESSAGE_TYPES.GET_STATE, payload: {} } + ]); +}); + +test("switching tabs while editing does not change the edited shortcut type", async () => { + const document = createPopupDocument(); + const chromeApi = createChromeApi(async (message) => { + if (message.type === MESSAGE_TYPES.UPDATE_SHORTCUT) { + return { ok: true, data: null }; + } + return { + ok: true, + data: { + shortcuts: [ + { id: "voice-1", name: "Voice Room", type: SHORTCUT_TYPES.VOICE, url: voiceUrl } + ], + windowState: { + windowId: null, + bounds: { width: 420, height: 900 }, + zoom: 0.9 + } + } + }; + }); + + const app = createPopupApp({ document, chromeApi }); + await app.init(); + + document.getElementById("voiceTab").dispatchEvent("click"); + document.getElementById("editingId").value = "voice-1"; + document.getElementById("shortcutName").value = "Voice Room"; + document.getElementById("shortcutUrl").value = voiceUrl; + document.getElementById("shortcutType").value = SHORTCUT_TYPES.VOICE; + + await document.getElementById("textTab").dispatchEvent("click"); + await document.getElementById("shortcutForm").dispatchEvent("submit"); + + assert.deepEqual(chromeApi.messages[1], { + type: MESSAGE_TYPES.UPDATE_SHORTCUT, + payload: { + id: "voice-1", + name: "Voice Room", + url: voiceUrl, + type: SHORTCUT_TYPES.VOICE + } + }); +}); + +test("edit action updates form title and cancel restores add title", async () => { + const document = createPopupDocument(); + const chromeApi = createChromeApi(async () => ({ + ok: true, + data: { + shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }], + windowState: { + windowId: null, + bounds: { width: 420, height: 900 }, + zoom: 0.9 + } + } + })); + + const app = createPopupApp({ document, chromeApi }); + await app.init(); + + const editButton = document.getElementById("shortcutList").findByText("E"); + assert.ok(editButton); + await editButton.dispatchEvent("click"); + + assert.equal(document.getElementById("formTitle").textContent, "Edit shortcut"); + assert.equal(document.getElementById("editingId").value, "text-1"); + + await document.getElementById("cancelEditButton").dispatchEvent("click"); + + assert.equal(document.getElementById("formTitle").textContent, "Add shortcut"); + assert.equal(document.getElementById("editingId").value, ""); +}); + +test("save current resets edit mode title before populating active tab", async () => { + const activeUrl = "https://discord.com/channels/323456789012345678/787654321098765432"; + const document = createPopupDocument(); + const chromeApi = createChromeApi(async (message) => { + if (message.type === MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB) { + return { + ok: true, + data: { + suggestedName: "Active Channel", + url: activeUrl + } + }; + } + return { + ok: true, + data: { + shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }], + windowState: { + windowId: null, + bounds: { width: 420, height: 900 }, + zoom: 0.9 + } + } + }; + }); + + const app = createPopupApp({ document, chromeApi }); + await app.init(); + + const editButton = document.getElementById("shortcutList").findByText("E"); + assert.ok(editButton); + await editButton.dispatchEvent("click"); + assert.equal(document.getElementById("formTitle").textContent, "Edit shortcut"); + + await document.getElementById("saveCurrentButton").dispatchEvent("click"); + + assert.equal(document.getElementById("editingId").value, ""); + assert.equal(document.getElementById("shortcutName").value, "Active Channel"); + assert.equal(document.getElementById("shortcutUrl").value, activeUrl); + assert.equal(document.getElementById("formTitle").textContent, "Add shortcut"); +}); + +test("search render does not overwrite unsaved settings values", async () => { + const document = createPopupDocument(); + const chromeApi = createChromeApi(async () => ({ + ok: true, + data: { + shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }], + windowState: { + windowId: 12, + bounds: { width: 420, height: 900 }, + zoom: 0.9 + } + } + })); + + const app = createPopupApp({ document, chromeApi }); + await app.init(); + + document.getElementById("widthInput").value = "777"; + document.getElementById("heightInput").value = "888"; + document.getElementById("zoomInput").value = "101"; + document.getElementById("searchInput").value = "dev"; + await document.getElementById("searchInput").dispatchEvent("input"); + + assert.equal(document.getElementById("widthInput").value, "777"); + assert.equal(document.getElementById("heightInput").value, "888"); + assert.equal(document.getElementById("zoomInput").value, "101"); +}); diff --git a/discord-mini-tabs-extension/test/service-worker-core.test.js b/discord-mini-tabs-extension/test/service-worker-core.test.js new file mode 100644 index 000000000..cbe07fb9d --- /dev/null +++ b/discord-mini-tabs-extension/test/service-worker-core.test.js @@ -0,0 +1,401 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServiceWorkerController } from "../src/background/service-worker-core.js"; +import { getExtensionState, setShortcuts, setWindowState } from "../src/background/storage.js"; +import { saveBoundsFromWindow } from "../src/background/window-manager.js"; +import { MESSAGE_TYPES } from "../src/shared/constants.js"; +import { createShortcut, deleteShortcut, findShortcut, updateShortcut } from "../src/shared/shortcuts.js"; +import { validateDiscordChannelUrl } from "../src/shared/url.js"; + +const firstUrl = "https://discord.com/channels/123456789012345678/987654321098765432"; +const secondUrl = "https://discord.com/channels/223456789012345678/887654321098765432"; + +function createFakeStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(defaults) { + return { ...defaults, ...data }; + }, + async set(values) { + Object.assign(data, values); + } + }; +} + +function createDeferred() { + let resolve; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function createController(storage, overrides = {}) { + return createServiceWorkerController({ + chromeApi: { + tabs: { + async query() { + return []; + } + } + }, + getExtensionState: () => getExtensionState(storage), + setShortcuts: (shortcuts) => setShortcuts(shortcuts, storage), + setWindowState: (windowState) => setWindowState(windowState, storage), + createShortcut, + deleteShortcut, + findShortcut, + updateShortcut, + validateDiscordChannelUrl, + openShortcutInMiniWindow: async ({ shortcut }) => ({ lastShortcutId: shortcut.id }), + focusMiniWindow: async () => 1, + closeMiniWindow: async () => ({}), + resetMiniWindowPosition: async () => ({}), + updateMiniWindowSettings: async ({ bounds, zoom }) => ({ bounds, zoom }), + saveBoundsFromWindow: (window, options) => + saveBoundsFromWindow(window, { storageArea: storage, ...options }), + ...overrides + }); +} + +test("serializes concurrent shortcut creates and preserves both shortcuts", async () => { + const storage = createFakeStorage(); + const controller = createController(storage); + + await Promise.all([ + controller.handleMessage({ + type: MESSAGE_TYPES.CREATE_SHORTCUT, + payload: { name: "First", type: "text", url: firstUrl } + }), + controller.handleMessage({ + type: MESSAGE_TYPES.CREATE_SHORTCUT, + payload: { name: "Second", type: "text", url: secondUrl } + }) + ]); + + assert.equal(storage.data.shortcuts.length, 2); + assert.deepEqual( + storage.data.shortcuts.map((shortcut) => shortcut.name).sort(), + ["First", "Second"] + ); +}); + +test("ignores unrelated window bounds events without cancelling a pending mini save", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 1, + tabId: 10, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: null + } + }); + const scheduled = []; + let nextTimerId = 1; + const controller = createController(storage, { + setTimeoutFn(callback) { + const timer = { id: nextTimerId++, callback, cancelled: false }; + scheduled.push(timer); + return timer.id; + }, + clearTimeoutFn(timerId) { + const timer = scheduled.find((item) => item.id === timerId); + if (timer) { + timer.cancelled = true; + } + }, + debounceMs: 400 + }); + + await controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 50, + top: 60, + width: 640, + height: 820 + }); + await controller.handleBoundsChanged({ + id: 2, + type: "normal", + left: 500, + top: 600, + width: 700, + height: 800 + }); + + await Promise.all(scheduled.filter((timer) => !timer.cancelled).map((timer) => timer.callback())); + + assert.deepEqual(storage.data.windowState.bounds, { + left: 50, + top: 60, + width: 640, + height: 820 + }); +}); + +test("preserves mini bounds event order when storage reads resolve out of order", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 1, + tabId: 10, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: null + } + }); + const scheduled = []; + const readDeferrals = []; + let nextTimerId = 1; + let deferReads = true; + const readState = () => ({ + shortcuts: storage.data.shortcuts ?? [], + windowState: storage.data.windowState + }); + const controller = createController(storage, { + getExtensionState() { + if (!deferReads) { + return Promise.resolve(readState()); + } + + const deferred = createDeferred(); + readDeferrals.push(deferred); + return deferred.promise; + }, + setTimeoutFn(callback) { + const timer = { id: nextTimerId++, callback, cancelled: false }; + scheduled.push(timer); + return timer.id; + }, + clearTimeoutFn(timerId) { + const timer = scheduled.find((item) => item.id === timerId); + if (timer) { + timer.cancelled = true; + } + }, + debounceMs: 400 + }); + + const firstEvent = controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 10, + top: 60, + width: 640, + height: 820 + }); + const secondEvent = controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 20, + top: 70, + width: 650, + height: 830 + }); + await Promise.resolve(); + deferReads = false; + + if (readDeferrals.length >= 2) { + readDeferrals[1].resolve(readState()); + await secondEvent; + readDeferrals[0].resolve(readState()); + await firstEvent; + } else { + await Promise.all([firstEvent, secondEvent]); + } + + await Promise.all(scheduled.filter((timer) => !timer.cancelled).map((timer) => timer.callback())); + + assert.equal(storage.data.windowState.bounds.left, 20); +}); + +test("unrelated popup bounds events do not cancel pending mini save", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 1, + tabId: 10, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "s1" + } + }); + const scheduled = []; + let nextTimerId = 1; + const controller = createController(storage, { + setTimeoutFn(callback) { + const timer = { id: nextTimerId++, callback, cancelled: false }; + scheduled.push(timer); + return timer.id; + }, + clearTimeoutFn(timerId) { + const timer = scheduled.find((item) => item.id === timerId); + if (timer) { + timer.cancelled = true; + } + }, + debounceMs: 400 + }); + + await controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 20, + top: 30, + width: 500, + height: 700 + }); + await controller.handleBoundsChanged({ + id: 2, + type: "popup", + left: 99, + top: 99, + width: 600, + height: 800 + }); + + await Promise.all(scheduled.filter((timer) => !timer.cancelled).map((timer) => timer.callback())); + + assert.equal(storage.data.windowState.bounds.left, 20); + assert.equal(storage.data.windowState.bounds.top, 30); +}); + +test("ignores stale in-flight bounds callbacks for the same window", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 1, + tabId: 10, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "s1" + } + }); + const scheduled = []; + const readDeferrals = []; + let nextTimerId = 1; + const readState = () => ({ + shortcuts: storage.data.shortcuts ?? [], + windowState: storage.data.windowState + }); + const controller = createController(storage, { + getExtensionState() { + const deferred = createDeferred(); + readDeferrals.push(deferred); + return deferred.promise; + }, + setTimeoutFn(callback) { + const timer = { id: nextTimerId++, callback, cancelled: false }; + scheduled.push(timer); + return timer.id; + }, + clearTimeoutFn(timerId) { + const timer = scheduled.find((item) => item.id === timerId); + if (timer) { + timer.cancelled = true; + } + }, + debounceMs: 400 + }); + + await controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 10, + top: 30, + width: 500, + height: 700 + }); + const firstCallback = scheduled.at(-1).callback(); + await flushMicrotasks(); + + await controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 20, + top: 40, + width: 510, + height: 710 + }); + const secondCallback = scheduled.at(-1).callback(); + await flushMicrotasks(); + + readDeferrals[1].resolve(readState()); + await secondCallback; + readDeferrals[0].resolve(readState()); + await firstCallback; + + assert.equal(storage.data.windowState.bounds.left, 20); + assert.equal(storage.data.windowState.bounds.top, 40); +}); + +test("ignores stale bounds callbacks that are already inside save", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 1, + tabId: 10, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "s1" + } + }); + const scheduled = []; + const saveDeferrals = []; + let nextTimerId = 1; + const controller = createController(storage, { + saveBoundsFromWindow(window, options) { + const deferred = createDeferred(); + saveDeferrals.push({ deferred, window, options }); + return deferred.promise.then(() => + saveBoundsFromWindow(window, { storageArea: storage, ...options }) + ); + }, + setTimeoutFn(callback) { + const timer = { id: nextTimerId++, callback, cancelled: false }; + scheduled.push(timer); + return timer.id; + }, + clearTimeoutFn(timerId) { + const timer = scheduled.find((item) => item.id === timerId); + if (timer) { + timer.cancelled = true; + } + }, + debounceMs: 400 + }); + + await controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 10, + top: 30, + width: 500, + height: 700 + }); + const firstCallback = scheduled.at(-1).callback(); + await flushMicrotasks(); + + await controller.handleBoundsChanged({ + id: 1, + type: "popup", + left: 20, + top: 40, + width: 510, + height: 710 + }); + const secondCallback = scheduled.at(-1).callback(); + await flushMicrotasks(); + + saveDeferrals[1].deferred.resolve(); + await secondCallback; + saveDeferrals[0].deferred.resolve(); + await firstCallback; + + assert.equal(storage.data.windowState.bounds.left, 20); + assert.equal(storage.data.windowState.bounds.top, 40); +}); diff --git a/discord-mini-tabs-extension/test/settings.test.js b/discord-mini-tabs-extension/test/settings.test.js new file mode 100644 index 000000000..a2d71d295 --- /dev/null +++ b/discord-mini-tabs-extension/test/settings.test.js @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + clampBounds, + clampZoom, + createDefaultWindowState, + normalizeWindowState +} from "../src/shared/settings.js"; + +test("creates default window state", () => { + assert.deepEqual(createDefaultWindowState(), { + windowId: null, + tabId: null, + bounds: { + left: null, + top: null, + width: 420, + height: 900 + }, + zoom: 0.9, + lastShortcutId: null + }); +}); + +test("clamps bounds to supported range", () => { + assert.deepEqual(clampBounds({ left: 20, top: 30, width: 100, height: 2000 }), { + left: 20, + top: 30, + width: 320, + height: 1400 + }); +}); + +test("keeps null left and top when unset", () => { + assert.deepEqual(clampBounds({ width: 500, height: 700 }), { + left: null, + top: null, + width: 500, + height: 700 + }); +}); + +test("clampBounds handles null input", () => { + assert.deepEqual(clampBounds(null), { + left: null, + top: null, + width: 420, + height: 900 + }); +}); + +test("clamps zoom to supported range", () => { + assert.equal(clampZoom(0.2), 0.67); + assert.equal(clampZoom(2), 1.25); + assert.equal(clampZoom(0.9), 0.9); +}); + +test("normalizes partial window state", () => { + const state = normalizeWindowState({ + windowId: 10, + bounds: { width: 640 }, + zoom: 1.5, + lastShortcutId: "abc" + }); + + assert.deepEqual(state, { + windowId: 10, + tabId: null, + bounds: { + left: null, + top: null, + width: 640, + height: 900 + }, + zoom: 1.25, + lastShortcutId: "abc" + }); +}); + +test("normalizeWindowState handles null input", () => { + assert.deepEqual(normalizeWindowState(null), createDefaultWindowState()); +}); diff --git a/discord-mini-tabs-extension/test/shortcuts.test.js b/discord-mini-tabs-extension/test/shortcuts.test.js new file mode 100644 index 000000000..50571ab94 --- /dev/null +++ b/discord-mini-tabs-extension/test/shortcuts.test.js @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createShortcut, + deleteShortcut, + filterShortcuts, + findShortcut, + splitShortcutsByType, + updateShortcut +} from "../src/shared/shortcuts.js"; + +const fixedNow = () => "2026-05-21T00:00:00.000Z"; +const fixedId = () => "shortcut-1"; +const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432"; +const voiceUrl = "https://discord.com/channels/223457890234567890/887650987650987650"; + +test("creates normalized text shortcut", () => { + const shortcut = createShortcut( + { + name: " dev chat ", + type: "text", + url: `${textUrl}/?jump=999` + }, + { idFactory: fixedId, now: fixedNow } + ); + + assert.deepEqual(shortcut, { + id: "shortcut-1", + name: "dev chat", + type: "text", + url: textUrl, + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:00:00.000Z" + }); +}); + +test("rejects empty shortcut name", () => { + assert.throws( + () => createShortcut({ name: " ", type: "text", url: textUrl }), + /name/ + ); +}); + +test("updates shortcut while preserving id and createdAt", () => { + const original = createShortcut( + { name: "dev chat", type: "text", url: textUrl }, + { idFactory: fixedId, now: fixedNow } + ); + const updated = updateShortcut(original, { + name: "team call", + type: "voice", + url: voiceUrl, + now: () => "2026-05-21T01:00:00.000Z" + }); + + assert.equal(updated.id, "shortcut-1"); + assert.equal(updated.createdAt, "2026-05-21T00:00:00.000Z"); + assert.equal(updated.updatedAt, "2026-05-21T01:00:00.000Z"); + assert.equal(updated.name, "team call"); + assert.equal(updated.type, "voice"); + assert.equal(updated.url, voiceUrl); +}); + +test("deletes shortcut by id", () => { + const shortcuts = [ + { id: "a", name: "A", type: "text", url: textUrl }, + { id: "b", name: "B", type: "voice", url: voiceUrl } + ]; + assert.deepEqual(deleteShortcut(shortcuts, "a").map((item) => item.id), ["b"]); +}); + +test("finds shortcuts by id and returns null when missing", () => { + const shortcuts = [ + { id: "a", name: "Dev Chat", type: "text", url: textUrl }, + { id: "b", name: "Team Call", type: "voice", url: voiceUrl } + ]; + + assert.equal(findShortcut(shortcuts, "b")?.name, "Team Call"); + assert.equal(findShortcut(shortcuts, "missing"), null); +}); + +test("filters shortcuts by name and url", () => { + const shortcuts = [ + { id: "a", name: "Dev Chat", type: "text", url: textUrl }, + { id: "b", name: "Team Call", type: "voice", url: voiceUrl } + ]; + assert.deepEqual(filterShortcuts(shortcuts, "team").map((item) => item.id), ["b"]); + assert.deepEqual(filterShortcuts(shortcuts, "123456").map((item) => item.id), ["a"]); + assert.deepEqual(filterShortcuts(shortcuts, "voice").map((item) => item.id), []); +}); + +test("splits shortcuts by type", () => { + const result = splitShortcutsByType([ + { id: "a", name: "Dev Chat", type: "text", url: textUrl }, + { id: "b", name: "Team Call", type: "voice", url: voiceUrl } + ]); + + assert.deepEqual(result.text.map((item) => item.id), ["a"]); + assert.deepEqual(result.voice.map((item) => item.id), ["b"]); +}); + +test("list helpers tolerate malformed shortcut lists", () => { + assert.deepEqual(deleteShortcut(null, "a"), []); + assert.equal(findShortcut(null, "a"), null); + assert.deepEqual(filterShortcuts(null, "dev"), []); + assert.deepEqual(splitShortcutsByType(null), { text: [], voice: [] }); +}); + +test("list helpers skip malformed shortcut items", () => { + const shortcuts = [ + null, + { id: "a", name: "Dev Chat", type: "text", url: textUrl }, + { id: "b", name: "Team Call", type: "voice", url: voiceUrl }, + { id: "broken", name: null, type: "text", url: null } + ]; + + assert.deepEqual(deleteShortcut(shortcuts, "a").map((item) => item.id), ["b"]); + assert.equal(findShortcut(shortcuts, "b")?.name, "Team Call"); + assert.deepEqual(filterShortcuts(shortcuts, "TEAM").map((item) => item.id), ["b"]); + assert.deepEqual(splitShortcutsByType(shortcuts).text.map((item) => item.id), ["a"]); +}); diff --git a/discord-mini-tabs-extension/test/storage.test.js b/discord-mini-tabs-extension/test/storage.test.js new file mode 100644 index 000000000..b6bc05781 --- /dev/null +++ b/discord-mini-tabs-extension/test/storage.test.js @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getExtensionState, + setShortcuts, + setWindowState, + updateWindowState +} from "../src/background/storage.js"; + +const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432"; +const voiceUrl = "https://discord.com/channels/223456789012345678/887654321098765432"; + +function createFakeStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(defaults) { + return { ...defaults, ...data }; + }, + async set(values) { + Object.assign(data, values); + } + }; +} + +test("returns normalized default state", async () => { + const storage = createFakeStorage(); + const state = await getExtensionState(storage); + + assert.deepEqual(state.shortcuts, []); + assert.equal(state.windowState.bounds.width, 420); + assert.equal(state.windowState.zoom, 0.9); +}); + +test("saves shortcuts", async () => { + const storage = createFakeStorage(); + await setShortcuts([{ id: "a", name: "Dev", type: "text", url: textUrl }], storage); + assert.equal(storage.data.shortcuts.length, 1); +}); + +test("filters malformed persisted shortcuts on read", async () => { + const valid = { id: "a", name: "Dev", type: "text", url: textUrl }; + const storage = createFakeStorage({ + shortcuts: [ + valid, + null, + { id: "broken", name: null, type: "text", url: textUrl }, + { id: "bad-type", name: "Bad", type: "bad", url: textUrl }, + { id: "bad-url", name: "Bad URL", type: "voice", url: null }, + { id: "b", name: "Voice", type: "voice", url: voiceUrl } + ] + }); + + const state = await getExtensionState(storage); + + assert.deepEqual(state.shortcuts.map((shortcut) => shortcut.id), ["a", "b"]); +}); + +test("filters malformed shortcuts on write", async () => { + const storage = createFakeStorage(); + const saved = await setShortcuts( + [ + { id: "a", name: "Dev", type: "text", url: textUrl }, + null, + { id: "broken", name: "Broken", type: "text", url: null } + ], + storage + ); + + assert.deepEqual(saved.map((shortcut) => shortcut.id), ["a"]); + assert.deepEqual(storage.data.shortcuts.map((shortcut) => shortcut.id), ["a"]); +}); + +test("normalizes saved window state", async () => { + const storage = createFakeStorage(); + await setWindowState({ bounds: { width: 100, height: 2000 }, zoom: 2 }, storage); + assert.equal(storage.data.windowState.bounds.width, 320); + assert.equal(storage.data.windowState.bounds.height, 1400); + assert.equal(storage.data.windowState.zoom, 1.25); +}); + +test("updates window state from previous value", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 5, + tabId: 6, + bounds: { left: null, top: null, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: null + } + }); + + const updated = await updateWindowState((state) => ({ + ...state, + bounds: { ...state.bounds, width: 640 } + }), storage); + + assert.equal(updated.windowId, 5); + assert.equal(updated.bounds.width, 640); + assert.equal(storage.data.windowState.bounds.width, 640); +}); diff --git a/discord-mini-tabs-extension/test/url.test.js b/discord-mini-tabs-extension/test/url.test.js new file mode 100644 index 000000000..6a4b86d25 --- /dev/null +++ b/discord-mini-tabs-extension/test/url.test.js @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + compactDiscordUrl, + normalizeDiscordChannelUrl, + validateDiscordChannelUrl +} from "../src/shared/url.js"; + +test("accepts server channel Discord URLs", () => { + const result = validateDiscordChannelUrl("https://discord.com/channels/123/456"); + assert.equal(result.ok, true); + assert.equal(result.url, "https://discord.com/channels/123/456"); + assert.equal(result.scope, "server"); +}); + +test("accepts direct message Discord URLs", () => { + const result = validateDiscordChannelUrl("https://discord.com/channels/@me/456"); + assert.equal(result.ok, true); + assert.equal(result.url, "https://discord.com/channels/@me/456"); + assert.equal(result.scope, "dm"); +}); + +test("normalizes trailing slash and search params", () => { + assert.equal( + normalizeDiscordChannelUrl("https://discord.com/channels/123/456/?jump=999"), + "https://discord.com/channels/123/456" + ); +}); + +test("rejects non-discord hosts", () => { + const result = validateDiscordChannelUrl("https://example.com/channels/123/456"); + assert.equal(result.ok, false); + assert.match(result.error, /discord\.com/); +}); + +test("rejects non-channel Discord URLs", () => { + const result = validateDiscordChannelUrl("https://discord.com/app"); + assert.equal(result.ok, false); + assert.match(result.error, /channels/); +}); + +test("rejects non-numeric server and channel ids", () => { + assert.equal(validateDiscordChannelUrl("https://discord.com/channels/foo/456").ok, false); + assert.equal(validateDiscordChannelUrl("https://discord.com/channels/123/bar").ok, false); + assert.equal(validateDiscordChannelUrl("https://discord.com/channels/@me/not-a-snowflake").ok, false); +}); + +test("rejects non-https discord URLs", () => { + const result = validateDiscordChannelUrl("http://discord.com/channels/123/456"); + assert.equal(result.ok, false); +}); + +test("rejects discord host spoofing", () => { + const result = validateDiscordChannelUrl("https://discord.com.evil.test/channels/123/456"); + assert.equal(result.ok, false); +}); + +test("returns compact display labels", () => { + assert.equal(compactDiscordUrl("https://discord.com/channels/123/456"), "123 / 456"); + assert.equal(compactDiscordUrl("https://discord.com/channels/@me/456"), "DM / 456"); +}); diff --git a/discord-mini-tabs-extension/test/view-model.test.js b/discord-mini-tabs-extension/test/view-model.test.js new file mode 100644 index 000000000..df0c2229a --- /dev/null +++ b/discord-mini-tabs-extension/test/view-model.test.js @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + buildPopupModel, + formatWindowStatus, + formatZoomPercent +} from "../src/popup/view-model.js"; + +const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432"; +const voiceUrl = "https://discord.com/channels/223456789012345678/887654321098765432"; + +const shortcuts = [ + { id: "a", name: "Dev Chat", type: "text", url: textUrl }, + { id: "b", name: "Team Call", type: "voice", url: voiceUrl } +]; + +test("formats mini window status", () => { + assert.equal(formatWindowStatus({ windowId: null }), "Closed"); + assert.equal(formatWindowStatus({ windowId: 10 }), "Open"); +}); + +test("formats zoom as rounded percent", () => { + assert.equal(formatZoomPercent(0.9), "90%"); + assert.equal(formatZoomPercent(1), "100%"); +}); + +test("builds popup model grouped by active text shortcuts", () => { + const model = buildPopupModel({ + shortcuts, + query: "", + activeType: "text", + windowState: { + windowId: 1, + tabId: 2, + bounds: { left: null, top: null, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "a" + } + }); + + assert.equal(model.status, "Open"); + assert.equal(model.zoomLabel, "90%"); + assert.equal(model.activeShortcuts.length, 1); + assert.equal(model.activeShortcuts[0].id, "a"); + assert.equal(model.textCount, 1); + assert.equal(model.voiceCount, 1); +}); + +test("filters popup shortcuts before applying active type", () => { + const model = buildPopupModel({ + shortcuts, + query: "team", + activeType: "voice", + windowState: { + windowId: null, + zoom: 1, + bounds: { width: 420, height: 900 } + } + }); + + assert.equal(model.activeShortcuts.length, 1); + assert.equal(model.activeShortcuts[0].id, "b"); +}); + +test("defaults popup zoom label when window state has no zoom", () => { + const model = buildPopupModel({ + shortcuts, + query: "", + activeType: "text", + windowState: { + windowId: null, + bounds: { width: 420, height: 900 } + } + }); + + assert.equal(model.zoomLabel, "90%"); +}); diff --git a/discord-mini-tabs-extension/test/window-manager.test.js b/discord-mini-tabs-extension/test/window-manager.test.js new file mode 100644 index 000000000..cfe856994 --- /dev/null +++ b/discord-mini-tabs-extension/test/window-manager.test.js @@ -0,0 +1,413 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + closeMiniWindow, + focusMiniWindow, + openShortcutInMiniWindow, + resetMiniWindowPosition, + saveBoundsFromWindow, + updateMiniWindowSettings +} from "../src/background/window-manager.js"; + +function createFakeStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(defaults) { + return { ...defaults, ...data }; + }, + async set(values) { + Object.assign(data, values); + } + }; +} + +function createFakeChrome({ + failSetZoom = false, + failWindowUpdate = false, + tabsUpdateReturnsUndefined = false, + windowsCreateReturnsUndefined = false +} = {}) { + const calls = []; + const windowsById = new Map(); + const tabsById = new Map(); + let nextWindowId = 100; + let nextTabId = 200; + + return { + calls, + windowsById, + tabsById, + windows: { + async create(createData) { + calls.push(["windows.create", createData]); + if (windowsCreateReturnsUndefined) { + return undefined; + } + + const windowId = nextWindowId++; + const tabId = nextTabId++; + const tab = { id: tabId, windowId, url: createData.url }; + const window = { + id: windowId, + type: createData.type, + focused: true, + left: createData.left, + top: createData.top, + width: createData.width, + height: createData.height, + tabs: [tab] + }; + windowsById.set(windowId, window); + tabsById.set(tabId, tab); + return window; + }, + async get(windowId, options) { + calls.push(["windows.get", windowId, options]); + const window = windowsById.get(windowId); + if (!window) throw new Error("No window"); + return options?.populate ? window : { ...window, tabs: undefined }; + }, + async update(windowId, updateInfo) { + calls.push(["windows.update", windowId, updateInfo]); + if (failWindowUpdate) throw new Error("Window update failed"); + const window = windowsById.get(windowId); + if (!window) throw new Error("No window"); + Object.assign(window, updateInfo); + return window; + }, + async remove(windowId) { + calls.push(["windows.remove", windowId]); + windowsById.delete(windowId); + } + }, + tabs: { + async update(tabId, updateInfo) { + calls.push(["tabs.update", tabId, updateInfo]); + const tab = tabsById.get(tabId); + if (!tab) throw new Error("No tab"); + Object.assign(tab, updateInfo); + if (tabsUpdateReturnsUndefined) { + return undefined; + } + + return tab; + }, + async setZoom(tabId, zoom) { + calls.push(["tabs.setZoom", tabId, zoom]); + if (failSetZoom) throw new Error("Zoom failed"); + } + } + }; +} + +function callsNamed(chromeApi, name) { + return chromeApi.calls.filter((call) => call[0] === name); +} + +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function deferWindowCreate(chromeApi) { + const started = createDeferred(); + const release = createDeferred(); + const createWindow = chromeApi.windows.create; + chromeApi.windows.create = async (createData) => { + started.resolve(); + await release.promise; + return createWindow(createData); + }; + return { started, release }; +} + +const shortcut = { + id: "s1", + name: "Dev Chat", + type: "text", + url: "https://discord.com/channels/123456789012345678/987654321098765432" +}; + +const nextShortcut = { + ...shortcut, + id: "s2", + url: "https://discord.com/channels/223456789012345678/887654321098765432" +}; + +test("creates a popup window when no valid window exists", async () => { + const chromeApi = createFakeChrome({ failSetZoom: true }); + const storage = createFakeStorage({ + windowState: { + windowId: 999, + tabId: 998 + } + }); + + const result = await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + assert.equal(result.windowId, 100); + assert.equal(result.tabId, 200); + assert.equal(storage.data.windowState.windowId, 100); + assert.equal(storage.data.windowState.tabId, 200); + assert.equal(storage.data.windowState.lastShortcutId, "s1"); + assert.equal(callsNamed(chromeApi, "windows.create").length, 1); + assert.equal(callsNamed(chromeApi, "windows.create")[0][1].type, "popup"); + assert.equal(callsNamed(chromeApi, "windows.create")[0][1].width, 420); + assert.equal(callsNamed(chromeApi, "windows.create")[0][1].height, 900); + assert.equal(callsNamed(chromeApi, "tabs.setZoom").length, 1); +}); + +test("reuses existing mini window and tab", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + const result = await openShortcutInMiniWindow({ + chromeApi, + storageArea: storage, + shortcut: nextShortcut + }); + + assert.equal(result.windowId, 100); + assert.equal(result.tabId, 200); + assert.equal(callsNamed(chromeApi, "windows.create").length, 1); + assert.ok( + chromeApi.calls.some( + (call) => call[0] === "windows.update" && call[1] === 100 && call[2].focused === true + ) + ); + assert.ok( + chromeApi.calls.some( + (call) => call[0] === "tabs.update" && call[1] === 200 && call[2].url === nextShortcut.url + ) + ); + assert.equal(storage.data.windowState.lastShortcutId, "s2"); +}); + +test("serializes concurrent open calls so only one popup is created", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + + const [first, second] = await Promise.all([ + openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }), + openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut: nextShortcut }) + ]); + + assert.equal(callsNamed(chromeApi, "windows.create").length, 1); + assert.equal(first.windowId, 100); + assert.equal(second.windowId, 100); + assert.equal(storage.data.windowState.windowId, 100); + assert.equal(storage.data.windowState.tabId, 200); + assert.equal(storage.data.windowState.lastShortcutId, "s2"); +}); + +test("serializes close after in-flight open so close wins", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + const deferredCreate = deferWindowCreate(chromeApi); + + const openPromise = openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + await deferredCreate.started.promise; + const closePromise = closeMiniWindow({ chromeApi, storageArea: storage }); + + deferredCreate.release.resolve(); + await Promise.all([openPromise, closePromise]); + + assert.equal(storage.data.windowState.windowId, null); + assert.equal(storage.data.windowState.tabId, null); + assert.equal(chromeApi.windowsById.has(100), false); +}); + +test("serializes settings after in-flight open so settings win", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + const deferredCreate = deferWindowCreate(chromeApi); + + const openPromise = openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + await deferredCreate.started.promise; + const settingsPromise = updateMiniWindowSettings({ + chromeApi, + storageArea: storage, + bounds: { width: 640, height: 820 }, + zoom: 1 + }); + + deferredCreate.release.resolve(); + await Promise.all([openPromise, settingsPromise]); + + assert.equal(storage.data.windowState.windowId, 100); + assert.equal(storage.data.windowState.bounds.width, 640); + assert.equal(storage.data.windowState.bounds.height, 820); + assert.equal(storage.data.windowState.zoom, 1); + assert.equal(chromeApi.windowsById.get(100).width, 640); + assert.equal(chromeApi.windowsById.get(100).height, 820); +}); + +test("focuses existing window", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + chromeApi.windowsById.get(100).focused = false; + + await focusMiniWindow({ chromeApi, storageArea: storage }); + + assert.equal(chromeApi.windowsById.get(100).focused, true); + assert.ok( + chromeApi.calls.some( + (call) => call[0] === "windows.update" && call[1] === 100 && call[2].focused === true + ) + ); +}); + +test("closes mini window and clears ids", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + await closeMiniWindow({ chromeApi, storageArea: storage }); + + assert.equal(callsNamed(chromeApi, "windows.remove").length, 1); + assert.equal(chromeApi.windowsById.has(100), false); + assert.equal(storage.data.windowState.windowId, null); + assert.equal(storage.data.windowState.tabId, null); +}); + +test("resets position but keeps size", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + await updateMiniWindowSettings({ + chromeApi, + storageArea: storage, + bounds: { left: 50, top: 60, width: 640, height: 820 }, + zoom: 1.1 + }); + + const state = await resetMiniWindowPosition({ chromeApi, storageArea: storage }); + + assert.equal(state.bounds.left, null); + assert.equal(state.bounds.top, null); + assert.equal(state.bounds.width, 640); + assert.equal(state.bounds.height, 820); + const lastWindowUpdate = callsNamed(chromeApi, "windows.update").at(-1); + assert.deepEqual(lastWindowUpdate[2], { width: 640, height: 820 }); +}); + +test("returns cleared state when reset position detects stale window", async () => { + const chromeApi = createFakeChrome({ failWindowUpdate: true }); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + const state = await resetMiniWindowPosition({ chromeApi, storageArea: storage }); + + assert.equal(state.windowId, null); + assert.equal(state.tabId, null); + assert.equal(storage.data.windowState.windowId, null); +}); + +test("returns cleared state when settings update detects stale window", async () => { + const chromeApi = createFakeChrome({ failWindowUpdate: true }); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + const state = await updateMiniWindowSettings({ + chromeApi, + storageArea: storage, + bounds: { width: 640, height: 820 }, + zoom: 1 + }); + + assert.equal(state.windowId, null); + assert.equal(state.tabId, null); + assert.equal(storage.data.windowState.windowId, null); +}); + +test("saves bounds from popup windows only", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 1, + tabId: 2, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "s1" + } + }); + + const ignoredNormalWindow = await saveBoundsFromWindow( + { id: 1, type: "normal", left: 10, top: 20, width: 500, height: 700 }, + { storageArea: storage, expectedWindowId: 1 } + ); + const ignoredWrongWindow = await saveBoundsFromWindow( + { id: 2, type: "popup", left: 10, top: 20, width: 500, height: 700 }, + { storageArea: storage, expectedWindowId: 1 } + ); + const saved = await saveBoundsFromWindow( + { id: 1, type: "popup", left: 10, top: 20, width: 500, height: 700 }, + { storageArea: storage, expectedWindowId: 1 } + ); + + assert.equal(ignoredNormalWindow, null); + assert.equal(ignoredWrongWindow, null); + assert.equal(saved.bounds.left, 10); + assert.equal(saved.bounds.top, 20); + assert.equal(saved.bounds.width, 500); + assert.equal(saved.bounds.height, 700); + assert.equal(storage.data.windowState.bounds.left, 10); +}); + +test("does not save bounds for a stale expected window id", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 2, + tabId: 20, + bounds: { left: 1, top: 2, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "s2" + } + }); + + const result = await saveBoundsFromWindow( + { id: 1, type: "popup", left: 10, top: 20, width: 500, height: 700 }, + { storageArea: storage, expectedWindowId: 1 } + ); + + assert.equal(result, null); + assert.equal(storage.data.windowState.windowId, 2); + assert.deepEqual(storage.data.windowState.bounds, { + left: 1, + top: 2, + width: 420, + height: 900 + }); +}); + +test("falls back to existing tab id when tabs.update returns undefined", async () => { + const chromeApi = createFakeChrome({ tabsUpdateReturnsUndefined: true }); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + const result = await openShortcutInMiniWindow({ + chromeApi, + storageArea: storage, + shortcut: nextShortcut + }); + + assert.equal(result.tabId, 200); + assert.equal(storage.data.windowState.tabId, 200); +}); + +test("throws custom error when chrome creates no usable window", async () => { + const chromeApi = createFakeChrome({ windowsCreateReturnsUndefined: true }); + const storage = createFakeStorage(); + + await assert.rejects( + openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }), + /usable Discord mini window/ + ); +}); diff --git a/docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md b/docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md new file mode 100644 index 000000000..f3da35b48 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md @@ -0,0 +1,2358 @@ +# Discord Mini Tabs Extension Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a lightweight Chrome MV3 extension that opens saved Discord text and voice channel URLs in one reusable, configurable Chrome popup window. + +**Architecture:** The extension is a standalone, dependency-light project under `discord-mini-tabs-extension/`. The popup renders shortcut/search/settings UI, while the MV3 service worker owns all Chrome API work for windows, tabs, zoom, and local storage. Discord runs only as the official Discord web app in a real Chrome popup window. + +**Tech Stack:** Plain JavaScript ES modules, Chrome Manifest V3, `chrome.storage.local`, `chrome.windows`, `chrome.tabs`, popup HTML/CSS, and Node's built-in `node:test` runner for pure logic tests. + +--- + +## Implementation Decisions + +- Use plain JavaScript ES modules and no build step. +- Use `node --test` for shared logic tests. +- Support only `https://discord.com/channels/...` in the first release. +- Store shortcuts and settings locally with `chrome.storage.local`. +- Do not add content scripts. +- Do not inspect or manipulate Discord DOM. +- Do not add import/export of shortcuts in the first release. + +## File Structure + +- `discord-mini-tabs-extension/package.json`: local scripts and module mode. +- `discord-mini-tabs-extension/manifest.json`: Chrome MV3 extension manifest. +- `discord-mini-tabs-extension/README.md`: load-unpacked and usage notes. +- `discord-mini-tabs-extension/src/shared/constants.js`: storage keys, default bounds, min/max clamps, message types. +- `discord-mini-tabs-extension/src/shared/url.js`: Discord URL validation and formatting. +- `discord-mini-tabs-extension/src/shared/settings.js`: bounds and zoom normalization. +- `discord-mini-tabs-extension/src/shared/shortcuts.js`: shortcut creation, mutation, grouping, and search. +- `discord-mini-tabs-extension/src/background/storage.js`: promise-based storage helpers. +- `discord-mini-tabs-extension/src/background/window-manager.js`: create/focus/update/close/reset mini Discord window. +- `discord-mini-tabs-extension/src/background/service-worker.js`: Chrome runtime message routing and window event listeners. +- `discord-mini-tabs-extension/src/popup/view-model.js`: pure UI view model helpers. +- `discord-mini-tabs-extension/src/popup/popup.html`: popup markup. +- `discord-mini-tabs-extension/src/popup/popup.css`: popup styling. +- `discord-mini-tabs-extension/src/popup/popup.js`: popup controller and DOM event handling. +- `discord-mini-tabs-extension/test/*.test.js`: Node tests for pure modules and injected Chrome API fakes. +- `discord-mini-tabs-extension/MANUAL_TESTS.md`: Chrome load-unpacked and stability smoke checklist. + +## Task 1: Scaffold The Standalone Extension Shell + +**Files:** +- Create: `discord-mini-tabs-extension/package.json` +- Create: `discord-mini-tabs-extension/manifest.json` +- Create: `discord-mini-tabs-extension/README.md` +- Create: `discord-mini-tabs-extension/src/background/service-worker.js` +- Create: `discord-mini-tabs-extension/src/popup/popup.html` +- Create: `discord-mini-tabs-extension/src/popup/popup.css` +- Create: `discord-mini-tabs-extension/src/popup/popup.js` + +- [ ] **Step 1: Create the directory tree** + +Run: + +```powershell +New-Item -ItemType Directory -Force ` + 'discord-mini-tabs-extension/src/background', ` + 'discord-mini-tabs-extension/src/popup', ` + 'discord-mini-tabs-extension/src/shared', ` + 'discord-mini-tabs-extension/test' +``` + +Expected: command exits 0 and the four directories exist. + +- [ ] **Step 2: Create the project metadata** + +Create `discord-mini-tabs-extension/package.json`: + +```json +{ + "name": "discord-mini-tabs-extension", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} +``` + +Create `discord-mini-tabs-extension/manifest.json`: + +```json +{ + "manifest_version": 3, + "name": "Discord Mini Tabs", + "version": "0.1.0", + "description": "Open saved Discord text and voice channels in one reusable mini Chrome popup window.", + "permissions": ["storage", "tabs", "windows", "activeTab"], + "host_permissions": ["https://discord.com/*"], + "background": { + "service_worker": "src/background/service-worker.js", + "type": "module" + }, + "action": { + "default_title": "Discord Mini Tabs", + "default_popup": "src/popup/popup.html" + } +} +``` + +- [ ] **Step 3: Create the initial README** + +Create `discord-mini-tabs-extension/README.md`: + +```markdown +# Discord Mini Tabs + +A Chrome Manifest V3 extension that opens saved Discord text and voice channel URLs in one reusable Chrome popup window. + +## Development + +Run pure logic tests: + +```bash +npm test +``` + +Load in Chrome: + +1. Open `chrome://extensions`. +2. Enable Developer mode. +3. Click Load unpacked. +4. Select the `discord-mini-tabs-extension` directory. + +## Scope + +Discord runs as the official Discord web app in a real Chrome popup window. This extension does not embed Discord, inject content scripts, read Discord messages, or inspect Discord voice internals. +``` + +- [ ] **Step 4: Add temporary valid extension entry files** + +Create `discord-mini-tabs-extension/src/background/service-worker.js`: + +```js +chrome.runtime.onInstalled.addListener(() => { + console.info("Discord Mini Tabs installed"); +}); +``` + +Create `discord-mini-tabs-extension/src/popup/popup.html`: + +```html + + + + + + Discord Mini Tabs + + + +
+

Discord Mini Tabs

+

Extension shell ready.

+
+ + + +``` + +Create `discord-mini-tabs-extension/src/popup/popup.css`: + +```css +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { + margin: 0; + width: 360px; + background: #17181c; + color: #f3f4f8; +} + +.shell { + padding: 16px; +} + +h1 { + margin: 0 0 8px; + font-size: 18px; + font-weight: 700; +} + +p { + margin: 0; + color: #aeb3c2; +} +``` + +Create `discord-mini-tabs-extension/src/popup/popup.js`: + +```js +console.info("Discord Mini Tabs popup loaded"); +``` + +- [ ] **Step 5: Run the initial test command** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: `node --test` exits 0 with no failing tests. + +- [ ] **Step 6: Commit the scaffold** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: scaffold discord mini tabs extension" +``` + +## Task 2: Add Shared URL And Settings Logic With Tests + +**Files:** +- Create: `discord-mini-tabs-extension/test/url.test.js` +- Create: `discord-mini-tabs-extension/test/settings.test.js` +- Create: `discord-mini-tabs-extension/src/shared/constants.js` +- Create: `discord-mini-tabs-extension/src/shared/url.js` +- Create: `discord-mini-tabs-extension/src/shared/settings.js` + +- [ ] **Step 1: Write failing URL tests** + +Create `discord-mini-tabs-extension/test/url.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { + compactDiscordUrl, + normalizeDiscordChannelUrl, + validateDiscordChannelUrl +} from "../src/shared/url.js"; + +test("accepts server channel Discord URLs", () => { + const result = validateDiscordChannelUrl("https://discord.com/channels/123/456"); + assert.equal(result.ok, true); + assert.equal(result.url, "https://discord.com/channels/123/456"); + assert.equal(result.scope, "server"); +}); + +test("accepts direct message Discord URLs", () => { + const result = validateDiscordChannelUrl("https://discord.com/channels/@me/456"); + assert.equal(result.ok, true); + assert.equal(result.url, "https://discord.com/channels/@me/456"); + assert.equal(result.scope, "dm"); +}); + +test("normalizes trailing slash and search params", () => { + assert.equal( + normalizeDiscordChannelUrl("https://discord.com/channels/123/456/?jump=999"), + "https://discord.com/channels/123/456" + ); +}); + +test("rejects non-discord hosts", () => { + const result = validateDiscordChannelUrl("https://example.com/channels/123/456"); + assert.equal(result.ok, false); + assert.match(result.error, /discord\.com/); +}); + +test("rejects non-channel Discord URLs", () => { + const result = validateDiscordChannelUrl("https://discord.com/app"); + assert.equal(result.ok, false); + assert.match(result.error, /channels/); +}); + +test("returns compact display labels", () => { + assert.equal(compactDiscordUrl("https://discord.com/channels/123/456"), "123 / 456"); + assert.equal(compactDiscordUrl("https://discord.com/channels/@me/456"), "DM / 456"); +}); +``` + +- [ ] **Step 2: Write failing settings tests** + +Create `discord-mini-tabs-extension/test/settings.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { + clampBounds, + clampZoom, + createDefaultWindowState, + normalizeWindowState +} from "../src/shared/settings.js"; + +test("creates default window state", () => { + assert.deepEqual(createDefaultWindowState(), { + windowId: null, + tabId: null, + bounds: { + left: null, + top: null, + width: 420, + height: 900 + }, + zoom: 0.9, + lastShortcutId: null + }); +}); + +test("clamps bounds to supported range", () => { + assert.deepEqual(clampBounds({ left: 20, top: 30, width: 100, height: 2000 }), { + left: 20, + top: 30, + width: 320, + height: 1400 + }); +}); + +test("keeps null left and top when unset", () => { + assert.deepEqual(clampBounds({ width: 500, height: 700 }), { + left: null, + top: null, + width: 500, + height: 700 + }); +}); + +test("clamps zoom to supported range", () => { + assert.equal(clampZoom(0.2), 0.67); + assert.equal(clampZoom(2), 1.25); + assert.equal(clampZoom(0.9), 0.9); +}); + +test("normalizes partial window state", () => { + const state = normalizeWindowState({ + windowId: 10, + bounds: { width: 640 }, + zoom: 1.5, + lastShortcutId: "abc" + }); + + assert.deepEqual(state, { + windowId: 10, + tabId: null, + bounds: { + left: null, + top: null, + width: 640, + height: 900 + }, + zoom: 1.25, + lastShortcutId: "abc" + }); +}); +``` + +- [ ] **Step 3: Run tests and verify the expected failure** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: FAIL because `src/shared/url.js` and `src/shared/settings.js` do not exist yet. + +- [ ] **Step 4: Implement shared constants** + +Create `discord-mini-tabs-extension/src/shared/constants.js`: + +```js +export const STORAGE_KEYS = { + SHORTCUTS: "shortcuts", + WINDOW_STATE: "windowState" +}; + +export const SHORTCUT_TYPES = { + TEXT: "text", + VOICE: "voice" +}; + +export const DEFAULT_BOUNDS = { + left: null, + top: null, + width: 420, + height: 900 +}; + +export const BOUNDS_LIMITS = { + minWidth: 320, + minHeight: 480, + maxWidth: 1200, + maxHeight: 1400 +}; + +export const DEFAULT_ZOOM = 0.9; +export const MIN_ZOOM = 0.67; +export const MAX_ZOOM = 1.25; + +export const DISCORD_HOST = "discord.com"; +export const DISCORD_CHANNEL_PREFIX = "/channels/"; + +export const MESSAGE_TYPES = { + GET_STATE: "GET_STATE", + CREATE_SHORTCUT: "CREATE_SHORTCUT", + UPDATE_SHORTCUT: "UPDATE_SHORTCUT", + DELETE_SHORTCUT: "DELETE_SHORTCUT", + OPEN_SHORTCUT: "OPEN_SHORTCUT", + READ_ACTIVE_DISCORD_TAB: "READ_ACTIVE_DISCORD_TAB", + UPDATE_WINDOW_SETTINGS: "UPDATE_WINDOW_SETTINGS", + FOCUS_WINDOW: "FOCUS_WINDOW", + CLOSE_WINDOW: "CLOSE_WINDOW", + RESET_POSITION: "RESET_POSITION" +}; +``` + +- [ ] **Step 5: Implement URL validation** + +Create `discord-mini-tabs-extension/src/shared/url.js`: + +```js +import { DISCORD_CHANNEL_PREFIX, DISCORD_HOST } from "./constants.js"; + +function parseUrl(input) { + if (typeof input !== "string" || input.trim().length === 0) { + return null; + } + + try { + return new URL(input.trim()); + } catch { + return null; + } +} + +function getChannelParts(url) { + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length !== 3 || parts[0] !== "channels") { + return null; + } + + const [, guildOrMe, channelId] = parts; + if (!guildOrMe || !channelId) { + return null; + } + + return { guildOrMe, channelId }; +} + +export function validateDiscordChannelUrl(input) { + const url = parseUrl(input); + if (!url) { + return { ok: false, error: "Enter a valid Discord channel URL." }; + } + + if (url.protocol !== "https:" || url.hostname !== DISCORD_HOST) { + return { ok: false, error: "Only https://discord.com channel URLs are supported." }; + } + + if (!url.pathname.startsWith(DISCORD_CHANNEL_PREFIX)) { + return { ok: false, error: "The URL must point to discord.com/channels/..." }; + } + + const parts = getChannelParts(url); + if (!parts) { + return { ok: false, error: "The Discord URL must include a server or DM id and channel id." }; + } + + const normalized = `https://${DISCORD_HOST}/channels/${parts.guildOrMe}/${parts.channelId}`; + return { + ok: true, + url: normalized, + guildOrMe: parts.guildOrMe, + channelId: parts.channelId, + scope: parts.guildOrMe === "@me" ? "dm" : "server" + }; +} + +export function normalizeDiscordChannelUrl(input) { + const result = validateDiscordChannelUrl(input); + if (!result.ok) { + throw new Error(result.error); + } + return result.url; +} + +export function compactDiscordUrl(input) { + const result = validateDiscordChannelUrl(input); + if (!result.ok) { + return ""; + } + const guildLabel = result.guildOrMe === "@me" ? "DM" : result.guildOrMe; + return `${guildLabel} / ${result.channelId}`; +} +``` + +- [ ] **Step 6: Implement settings normalization** + +Create `discord-mini-tabs-extension/src/shared/settings.js`: + +```js +import { + BOUNDS_LIMITS, + DEFAULT_BOUNDS, + DEFAULT_ZOOM, + MAX_ZOOM, + MIN_ZOOM +} from "./constants.js"; + +function clampNumber(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.round(number))); +} + +function normalizeNullableCoordinate(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.round(number) : null; +} + +export function clampBounds(bounds = {}) { + return { + left: normalizeNullableCoordinate(bounds.left), + top: normalizeNullableCoordinate(bounds.top), + width: clampNumber( + bounds.width, + BOUNDS_LIMITS.minWidth, + BOUNDS_LIMITS.maxWidth, + DEFAULT_BOUNDS.width + ), + height: clampNumber( + bounds.height, + BOUNDS_LIMITS.minHeight, + BOUNDS_LIMITS.maxHeight, + DEFAULT_BOUNDS.height + ) + }; +} + +export function clampZoom(value) { + const number = Number(value); + if (!Number.isFinite(number)) { + return DEFAULT_ZOOM; + } + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(number.toFixed(2)))); +} + +export function createDefaultWindowState() { + return { + windowId: null, + tabId: null, + bounds: { ...DEFAULT_BOUNDS }, + zoom: DEFAULT_ZOOM, + lastShortcutId: null + }; +} + +export function normalizeWindowState(input = {}) { + const defaults = createDefaultWindowState(); + return { + windowId: Number.isInteger(input.windowId) ? input.windowId : null, + tabId: Number.isInteger(input.tabId) ? input.tabId : null, + bounds: clampBounds({ ...defaults.bounds, ...input.bounds }), + zoom: clampZoom(input.zoom ?? defaults.zoom), + lastShortcutId: + typeof input.lastShortcutId === "string" && input.lastShortcutId.length > 0 + ? input.lastShortcutId + : null + }; +} +``` + +- [ ] **Step 7: Run tests and verify they pass** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for `url.test.js` and `settings.test.js`. + +- [ ] **Step 8: Commit shared logic** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: add discord url and settings logic" +``` + +## Task 3: Add Shortcut Creation, Editing, Deletion, Grouping, And Search + +**Files:** +- Create: `discord-mini-tabs-extension/test/shortcuts.test.js` +- Create: `discord-mini-tabs-extension/src/shared/shortcuts.js` + +- [ ] **Step 1: Write failing shortcut tests** + +Create `discord-mini-tabs-extension/test/shortcuts.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createShortcut, + deleteShortcut, + filterShortcuts, + splitShortcutsByType, + updateShortcut +} from "../src/shared/shortcuts.js"; + +const fixedNow = () => "2026-05-21T00:00:00.000Z"; +const fixedId = () => "shortcut-1"; + +test("creates normalized text shortcut", () => { + const shortcut = createShortcut( + { + name: " dev chat ", + type: "text", + url: "https://discord.com/channels/123/456/?jump=1" + }, + { idFactory: fixedId, now: fixedNow } + ); + + assert.deepEqual(shortcut, { + id: "shortcut-1", + name: "dev chat", + type: "text", + url: "https://discord.com/channels/123/456", + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:00:00.000Z" + }); +}); + +test("rejects empty shortcut name", () => { + assert.throws( + () => createShortcut({ name: " ", type: "text", url: "https://discord.com/channels/123/456" }), + /name/ + ); +}); + +test("updates shortcut while preserving id and createdAt", () => { + const original = createShortcut( + { name: "dev chat", type: "text", url: "https://discord.com/channels/123/456" }, + { idFactory: fixedId, now: fixedNow } + ); + const updated = updateShortcut(original, { + name: "team call", + type: "voice", + url: "https://discord.com/channels/789/111", + now: () => "2026-05-21T01:00:00.000Z" + }); + + assert.equal(updated.id, "shortcut-1"); + assert.equal(updated.createdAt, "2026-05-21T00:00:00.000Z"); + assert.equal(updated.updatedAt, "2026-05-21T01:00:00.000Z"); + assert.equal(updated.type, "voice"); + assert.equal(updated.url, "https://discord.com/channels/789/111"); +}); + +test("deletes shortcut by id", () => { + const shortcuts = [ + { id: "a", name: "A", type: "text", url: "https://discord.com/channels/1/2" }, + { id: "b", name: "B", type: "voice", url: "https://discord.com/channels/3/4" } + ]; + assert.deepEqual(deleteShortcut(shortcuts, "a").map((item) => item.id), ["b"]); +}); + +test("filters shortcuts by name and url", () => { + const shortcuts = [ + { id: "a", name: "Dev Chat", type: "text", url: "https://discord.com/channels/1/2" }, + { id: "b", name: "Team Call", type: "voice", url: "https://discord.com/channels/3/4" } + ]; + assert.deepEqual(filterShortcuts(shortcuts, "team").map((item) => item.id), ["b"]); + assert.deepEqual(filterShortcuts(shortcuts, "channels/1").map((item) => item.id), ["a"]); +}); + +test("splits shortcuts by type", () => { + const result = splitShortcutsByType([ + { id: "a", name: "Dev Chat", type: "text", url: "https://discord.com/channels/1/2" }, + { id: "b", name: "Team Call", type: "voice", url: "https://discord.com/channels/3/4" } + ]); + + assert.deepEqual(result.text.map((item) => item.id), ["a"]); + assert.deepEqual(result.voice.map((item) => item.id), ["b"]); +}); +``` + +- [ ] **Step 2: Run tests and verify the expected failure** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: FAIL because `src/shared/shortcuts.js` does not exist. + +- [ ] **Step 3: Implement shortcut helpers** + +Create `discord-mini-tabs-extension/src/shared/shortcuts.js`: + +```js +import { SHORTCUT_TYPES } from "./constants.js"; +import { normalizeDiscordChannelUrl } from "./url.js"; + +const VALID_TYPES = new Set([SHORTCUT_TYPES.TEXT, SHORTCUT_TYPES.VOICE]); + +function defaultIdFactory() { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function defaultNow() { + return new Date().toISOString(); +} + +function normalizeName(name) { + const value = String(name ?? "").trim(); + if (value.length === 0) { + throw new Error("Shortcut name is required."); + } + return value; +} + +function normalizeType(type) { + if (!VALID_TYPES.has(type)) { + throw new Error("Shortcut type must be text or voice."); + } + return type; +} + +export function createShortcut(input, options = {}) { + const idFactory = options.idFactory ?? defaultIdFactory; + const now = options.now ?? defaultNow; + const timestamp = now(); + + return { + id: idFactory(), + name: normalizeName(input.name), + type: normalizeType(input.type), + url: normalizeDiscordChannelUrl(input.url), + createdAt: timestamp, + updatedAt: timestamp + }; +} + +export function updateShortcut(existing, input) { + const now = input.now ?? defaultNow; + return { + ...existing, + name: normalizeName(input.name ?? existing.name), + type: normalizeType(input.type ?? existing.type), + url: normalizeDiscordChannelUrl(input.url ?? existing.url), + updatedAt: now() + }; +} + +export function deleteShortcut(shortcuts, id) { + return shortcuts.filter((shortcut) => shortcut.id !== id); +} + +export function findShortcut(shortcuts, id) { + return shortcuts.find((shortcut) => shortcut.id === id) ?? null; +} + +export function filterShortcuts(shortcuts, query) { + const normalizedQuery = String(query ?? "").trim().toLowerCase(); + if (!normalizedQuery) { + return shortcuts; + } + + return shortcuts.filter((shortcut) => { + const haystack = `${shortcut.name} ${shortcut.type} ${shortcut.url}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }); +} + +export function splitShortcutsByType(shortcuts) { + return { + text: shortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.TEXT), + voice: shortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.VOICE) + }; +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for URL, settings, and shortcut tests. + +- [ ] **Step 5: Commit shortcut logic** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: add shortcut management logic" +``` + +## Task 4: Add Storage Helpers With Tests + +**Files:** +- Create: `discord-mini-tabs-extension/test/storage.test.js` +- Create: `discord-mini-tabs-extension/src/background/storage.js` + +- [ ] **Step 1: Write failing storage tests** + +Create `discord-mini-tabs-extension/test/storage.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getExtensionState, + setShortcuts, + setWindowState, + updateWindowState +} from "../src/background/storage.js"; + +function createFakeStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(defaults) { + return { ...defaults, ...data }; + }, + async set(values) { + Object.assign(data, values); + } + }; +} + +test("returns normalized default state", async () => { + const storage = createFakeStorage(); + const state = await getExtensionState(storage); + + assert.deepEqual(state.shortcuts, []); + assert.equal(state.windowState.bounds.width, 420); + assert.equal(state.windowState.zoom, 0.9); +}); + +test("saves shortcuts", async () => { + const storage = createFakeStorage(); + await setShortcuts([{ id: "a", name: "Dev", type: "text", url: "https://discord.com/channels/1/2" }], storage); + assert.equal(storage.data.shortcuts.length, 1); +}); + +test("normalizes saved window state", async () => { + const storage = createFakeStorage(); + await setWindowState({ bounds: { width: 100, height: 2000 }, zoom: 2 }, storage); + assert.equal(storage.data.windowState.bounds.width, 320); + assert.equal(storage.data.windowState.bounds.height, 1400); + assert.equal(storage.data.windowState.zoom, 1.25); +}); + +test("updates window state from previous value", async () => { + const storage = createFakeStorage({ + windowState: { + windowId: 5, + tabId: 6, + bounds: { left: null, top: null, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: null + } + }); + + const updated = await updateWindowState((state) => ({ + ...state, + bounds: { ...state.bounds, width: 640 } + }), storage); + + assert.equal(updated.windowId, 5); + assert.equal(updated.bounds.width, 640); + assert.equal(storage.data.windowState.bounds.width, 640); +}); +``` + +- [ ] **Step 2: Run tests and verify the expected failure** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: FAIL because `src/background/storage.js` does not exist. + +- [ ] **Step 3: Implement storage helpers** + +Create `discord-mini-tabs-extension/src/background/storage.js`: + +```js +import { STORAGE_KEYS } from "../shared/constants.js"; +import { createDefaultWindowState, normalizeWindowState } from "../shared/settings.js"; + +const DEFAULT_STATE = { + [STORAGE_KEYS.SHORTCUTS]: [], + [STORAGE_KEYS.WINDOW_STATE]: createDefaultWindowState() +}; + +function normalizeShortcuts(value) { + return Array.isArray(value) ? value : []; +} + +function getDefaultStorageArea() { + return chrome.storage.local; +} + +export async function getExtensionState(storageArea = getDefaultStorageArea()) { + const data = await storageArea.get(DEFAULT_STATE); + return { + shortcuts: normalizeShortcuts(data[STORAGE_KEYS.SHORTCUTS]), + windowState: normalizeWindowState(data[STORAGE_KEYS.WINDOW_STATE]) + }; +} + +export async function setShortcuts(shortcuts, storageArea = getDefaultStorageArea()) { + const normalized = normalizeShortcuts(shortcuts); + await storageArea.set({ [STORAGE_KEYS.SHORTCUTS]: normalized }); + return normalized; +} + +export async function setWindowState(windowState, storageArea = getDefaultStorageArea()) { + const normalized = normalizeWindowState(windowState); + await storageArea.set({ [STORAGE_KEYS.WINDOW_STATE]: normalized }); + return normalized; +} + +export async function updateWindowState(updater, storageArea = getDefaultStorageArea()) { + const state = await getExtensionState(storageArea); + const nextWindowState = normalizeWindowState(updater(state.windowState)); + await setWindowState(nextWindowState, storageArea); + return nextWindowState; +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for URL, settings, shortcuts, and storage tests. + +- [ ] **Step 5: Commit storage helpers** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: add extension storage helpers" +``` + +## Task 5: Add Window Manager With Chrome API Fakes + +**Files:** +- Create: `discord-mini-tabs-extension/test/window-manager.test.js` +- Create: `discord-mini-tabs-extension/src/background/window-manager.js` + +- [ ] **Step 1: Write failing window manager tests** + +Create `discord-mini-tabs-extension/test/window-manager.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { + closeMiniWindow, + focusMiniWindow, + openShortcutInMiniWindow, + resetMiniWindowPosition, + saveBoundsFromWindow +} from "../src/background/window-manager.js"; + +function createFakeStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(defaults) { + return { ...defaults, ...data }; + }, + async set(values) { + Object.assign(data, values); + } + }; +} + +function createFakeChrome() { + const calls = []; + const windowsById = new Map(); + const tabsById = new Map(); + let nextWindowId = 100; + let nextTabId = 200; + + return { + calls, + windowsById, + tabsById, + windows: { + async create(createData) { + calls.push(["windows.create", createData]); + const windowId = nextWindowId++; + const tabId = nextTabId++; + const tab = { id: tabId, windowId, url: createData.url }; + const window = { + id: windowId, + type: createData.type, + focused: true, + left: createData.left, + top: createData.top, + width: createData.width, + height: createData.height, + tabs: [tab] + }; + windowsById.set(windowId, window); + tabsById.set(tabId, tab); + return window; + }, + async get(windowId, options) { + calls.push(["windows.get", windowId, options]); + const window = windowsById.get(windowId); + if (!window) throw new Error("No window"); + return options?.populate ? window : { ...window, tabs: undefined }; + }, + async update(windowId, updateInfo) { + calls.push(["windows.update", windowId, updateInfo]); + const window = windowsById.get(windowId); + if (!window) throw new Error("No window"); + Object.assign(window, updateInfo); + return window; + }, + async remove(windowId) { + calls.push(["windows.remove", windowId]); + windowsById.delete(windowId); + } + }, + tabs: { + async update(tabId, updateInfo) { + calls.push(["tabs.update", tabId, updateInfo]); + const tab = tabsById.get(tabId); + if (!tab) throw new Error("No tab"); + Object.assign(tab, updateInfo); + return tab; + }, + async setZoom(tabId, zoom) { + calls.push(["tabs.setZoom", tabId, zoom]); + } + } + }; +} + +const shortcut = { + id: "s1", + name: "Dev Chat", + type: "text", + url: "https://discord.com/channels/1/2" +}; + +test("creates a popup window when no valid window exists", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + + const result = await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + assert.equal(result.windowId, 100); + assert.equal(result.tabId, 200); + assert.equal(storage.data.windowState.windowId, 100); + assert.equal(storage.data.windowState.tabId, 200); + assert.equal(storage.data.windowState.lastShortcutId, "s1"); + assert.deepEqual(chromeApi.calls[0][0], "windows.create"); + assert.equal(chromeApi.calls[0][1].type, "popup"); + assert.equal(chromeApi.calls[0][1].width, 420); + assert.equal(chromeApi.calls[0][1].height, 900); +}); + +test("reuses existing mini window and tab", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + const nextShortcut = { ...shortcut, id: "s2", url: "https://discord.com/channels/3/4" }; + const result = await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut: nextShortcut }); + + assert.equal(result.windowId, 100); + assert.equal(result.tabId, 200); + assert.ok(chromeApi.calls.some((call) => call[0] === "tabs.update" && call[2].url === nextShortcut.url)); + assert.equal(storage.data.windowState.lastShortcutId, "s2"); +}); + +test("focuses existing window", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + await focusMiniWindow({ chromeApi, storageArea: storage }); + + assert.ok(chromeApi.calls.some((call) => call[0] === "windows.update" && call[2].focused === true)); +}); + +test("closes mini window and clears ids", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + await closeMiniWindow({ chromeApi, storageArea: storage }); + + assert.equal(storage.data.windowState.windowId, null); + assert.equal(storage.data.windowState.tabId, null); +}); + +test("resets position but keeps size", async () => { + const chromeApi = createFakeChrome(); + const storage = createFakeStorage(); + await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }); + + const state = await resetMiniWindowPosition({ chromeApi, storageArea: storage }); + + assert.equal(state.bounds.left, null); + assert.equal(state.bounds.top, null); + assert.equal(state.bounds.width, 420); +}); + +test("saves bounds from popup windows only", async () => { + const storage = createFakeStorage(); + const saved = await saveBoundsFromWindow( + { id: 1, type: "popup", left: 10, top: 20, width: 500, height: 700 }, + { storageArea: storage, expectedWindowId: 1 } + ); + + assert.equal(saved.bounds.left, 10); + assert.equal(saved.bounds.width, 500); +}); +``` + +- [ ] **Step 2: Run tests and verify the expected failure** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: FAIL because `src/background/window-manager.js` does not exist. + +- [ ] **Step 3: Implement the window manager** + +Create `discord-mini-tabs-extension/src/background/window-manager.js`: + +```js +import { DEFAULT_BOUNDS } from "../shared/constants.js"; +import { clampBounds, normalizeWindowState } from "../shared/settings.js"; +import { getExtensionState, setWindowState, updateWindowState } from "./storage.js"; + +function getDefaultChromeApi() { + return chrome; +} + +function boundsToCreateData(bounds, url) { + const createData = { + url, + type: "popup", + focused: true, + width: bounds.width, + height: bounds.height + }; + + if (Number.isInteger(bounds.left)) createData.left = bounds.left; + if (Number.isInteger(bounds.top)) createData.top = bounds.top; + + return createData; +} + +async function getWindowWithTab(chromeApi, windowState) { + if (!Number.isInteger(windowState.windowId)) { + return null; + } + + try { + const currentWindow = await chromeApi.windows.get(windowState.windowId, { populate: true }); + const existingTab = currentWindow.tabs?.find((tab) => tab.id === windowState.tabId) ?? currentWindow.tabs?.[0] ?? null; + if (!existingTab?.id) { + return null; + } + return { window: currentWindow, tab: existingTab }; + } catch { + return null; + } +} + +async function applyZoom(chromeApi, tabId, zoom) { + try { + await chromeApi.tabs.setZoom(tabId, zoom); + return true; + } catch { + return false; + } +} + +export async function openShortcutInMiniWindow({ + chromeApi = getDefaultChromeApi(), + storageArea, + shortcut +}) { + const state = await getExtensionState(storageArea); + const windowState = normalizeWindowState(state.windowState); + const existing = await getWindowWithTab(chromeApi, windowState); + + if (existing) { + await chromeApi.windows.update(existing.window.id, { focused: true }); + const tab = await chromeApi.tabs.update(existing.tab.id, { url: shortcut.url, active: true }); + await applyZoom(chromeApi, existing.tab.id, windowState.zoom); + const nextState = await setWindowState( + { + ...windowState, + windowId: existing.window.id, + tabId: tab.id ?? existing.tab.id, + lastShortcutId: shortcut.id + }, + storageArea + ); + return nextState; + } + + const bounds = clampBounds(windowState.bounds ?? DEFAULT_BOUNDS); + const createdWindow = await chromeApi.windows.create(boundsToCreateData(bounds, shortcut.url)); + const createdTab = createdWindow.tabs?.[0] ?? null; + if (!createdWindow.id || !createdTab?.id) { + throw new Error("Chrome did not return a usable Discord mini window."); + } + + await applyZoom(chromeApi, createdTab.id, windowState.zoom); + return setWindowState( + { + ...windowState, + windowId: createdWindow.id, + tabId: createdTab.id, + bounds, + lastShortcutId: shortcut.id + }, + storageArea + ); +} + +export async function focusMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) { + const state = await getExtensionState(storageArea); + const existing = await getWindowWithTab(chromeApi, state.windowState); + if (!existing) { + await setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea); + return null; + } + + await chromeApi.windows.update(existing.window.id, { focused: true }); + return existing.window.id; +} + +export async function closeMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) { + const state = await getExtensionState(storageArea); + if (Number.isInteger(state.windowState.windowId)) { + try { + await chromeApi.windows.remove(state.windowState.windowId); + } catch { + // The window may already be closed. Clearing stored ids is still correct. + } + } + + return setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea); +} + +export async function resetMiniWindowPosition({ chromeApi = getDefaultChromeApi(), storageArea }) { + const state = await updateWindowState( + (windowState) => ({ + ...windowState, + bounds: { ...windowState.bounds, left: null, top: null } + }), + storageArea + ); + + if (Number.isInteger(state.windowId)) { + try { + await chromeApi.windows.update(state.windowId, { + width: state.bounds.width, + height: state.bounds.height + }); + } catch { + await setWindowState({ ...state, windowId: null, tabId: null }, storageArea); + } + } + + return state; +} + +export async function updateMiniWindowSettings({ chromeApi = getDefaultChromeApi(), storageArea, bounds, zoom }) { + const state = await updateWindowState( + (windowState) => ({ + ...windowState, + bounds: clampBounds({ ...windowState.bounds, ...bounds }), + zoom + }), + storageArea + ); + + if (Number.isInteger(state.windowId)) { + try { + const updateInfo = { width: state.bounds.width, height: state.bounds.height }; + if (Number.isInteger(state.bounds.left)) updateInfo.left = state.bounds.left; + if (Number.isInteger(state.bounds.top)) updateInfo.top = state.bounds.top; + await chromeApi.windows.update(state.windowId, updateInfo); + if (Number.isInteger(state.tabId)) { + await applyZoom(chromeApi, state.tabId, state.zoom); + } + } catch { + await setWindowState({ ...state, windowId: null, tabId: null }, storageArea); + } + } + + return state; +} + +export async function saveBoundsFromWindow(window, { storageArea, expectedWindowId }) { + if (!window || window.type !== "popup" || window.id !== expectedWindowId) { + return null; + } + + return updateWindowState( + (windowState) => ({ + ...windowState, + bounds: clampBounds({ + left: window.left, + top: window.top, + width: window.width, + height: window.height + }) + }), + storageArea + ); +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for all tests, including `window-manager.test.js`. + +- [ ] **Step 5: Commit window manager** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: manage reusable discord mini window" +``` + +## Task 6: Replace The Service Worker With Runtime Message Routing + +**Files:** +- Modify: `discord-mini-tabs-extension/src/background/service-worker.js` + +- [ ] **Step 1: Replace the service worker implementation** + +Modify `discord-mini-tabs-extension/src/background/service-worker.js`: + +```js +import { MESSAGE_TYPES } from "../shared/constants.js"; +import { createShortcut, deleteShortcut, findShortcut, updateShortcut } from "../shared/shortcuts.js"; +import { validateDiscordChannelUrl } from "../shared/url.js"; +import { getExtensionState, setShortcuts, setWindowState } from "./storage.js"; +import { + closeMiniWindow, + focusMiniWindow, + openShortcutInMiniWindow, + resetMiniWindowPosition, + saveBoundsFromWindow, + updateMiniWindowSettings +} from "./window-manager.js"; + +let boundsSaveTimer = null; + +async function handleCreateShortcut(payload) { + const state = await getExtensionState(); + const shortcut = createShortcut(payload); + const shortcuts = await setShortcuts([...state.shortcuts, shortcut]); + return { shortcut, shortcuts }; +} + +async function handleUpdateShortcut(payload) { + const state = await getExtensionState(); + const existing = findShortcut(state.shortcuts, payload.id); + if (!existing) { + throw new Error("Shortcut not found."); + } + + const updated = updateShortcut(existing, payload); + const shortcuts = await setShortcuts( + state.shortcuts.map((shortcut) => (shortcut.id === updated.id ? updated : shortcut)) + ); + return { shortcut: updated, shortcuts }; +} + +async function handleDeleteShortcut(payload) { + const state = await getExtensionState(); + const shortcuts = await setShortcuts(deleteShortcut(state.shortcuts, payload.id)); + return { shortcuts }; +} + +async function handleOpenShortcut(payload) { + const state = await getExtensionState(); + const shortcut = findShortcut(state.shortcuts, payload.id); + if (!shortcut) { + throw new Error("Shortcut not found."); + } + + const windowState = await openShortcutInMiniWindow({ shortcut }); + return { windowState }; +} + +async function handleReadActiveDiscordTab() { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const activeTab = tabs[0]; + const result = validateDiscordChannelUrl(activeTab?.url ?? ""); + if (!result.ok) { + throw new Error("The active tab is not a supported Discord channel URL."); + } + + return { + url: result.url, + title: activeTab.title ?? "", + suggestedName: activeTab.title?.replace(/\s+-\s+Discord$/, "").trim() || "Discord channel" + }; +} + +async function handleMessage(message) { + const payload = message?.payload ?? {}; + + switch (message?.type) { + case MESSAGE_TYPES.GET_STATE: + return getExtensionState(); + case MESSAGE_TYPES.CREATE_SHORTCUT: + return handleCreateShortcut(payload); + case MESSAGE_TYPES.UPDATE_SHORTCUT: + return handleUpdateShortcut(payload); + case MESSAGE_TYPES.DELETE_SHORTCUT: + return handleDeleteShortcut(payload); + case MESSAGE_TYPES.OPEN_SHORTCUT: + return handleOpenShortcut(payload); + case MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB: + return handleReadActiveDiscordTab(); + case MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS: + return { + windowState: await updateMiniWindowSettings({ + bounds: payload.bounds, + zoom: payload.zoom + }) + }; + case MESSAGE_TYPES.FOCUS_WINDOW: + return { windowId: await focusMiniWindow({}) }; + case MESSAGE_TYPES.CLOSE_WINDOW: + return { windowState: await closeMiniWindow({}) }; + case MESSAGE_TYPES.RESET_POSITION: + return { windowState: await resetMiniWindowPosition({}) }; + default: + throw new Error("Unsupported message type."); + } +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + handleMessage(message) + .then((data) => sendResponse({ ok: true, data })) + .catch((error) => sendResponse({ ok: false, error: error.message })); + return true; +}); + +chrome.windows.onRemoved.addListener(async (windowId) => { + const state = await getExtensionState(); + if (state.windowState.windowId === windowId) { + await setWindowState({ ...state.windowState, windowId: null, tabId: null }); + } +}); + +chrome.windows.onBoundsChanged.addListener((changedWindow) => { + clearTimeout(boundsSaveTimer); + boundsSaveTimer = setTimeout(async () => { + const state = await getExtensionState(); + await saveBoundsFromWindow(changedWindow, { + expectedWindowId: state.windowState.windowId + }); + }, 400); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS. The service worker itself is exercised manually in later tasks because it depends on live Chrome extension APIs. + +- [ ] **Step 3: Commit service worker routing** + +```powershell +git add discord-mini-tabs-extension/src/background/service-worker.js +git commit -m "feat: route extension runtime messages" +``` + +## Task 7: Add Popup View Model Tests + +**Files:** +- Create: `discord-mini-tabs-extension/test/view-model.test.js` +- Create: `discord-mini-tabs-extension/src/popup/view-model.js` + +- [ ] **Step 1: Write failing view model tests** + +Create `discord-mini-tabs-extension/test/view-model.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildPopupModel, formatWindowStatus, formatZoomPercent } from "../src/popup/view-model.js"; + +const shortcuts = [ + { id: "a", name: "Dev Chat", type: "text", url: "https://discord.com/channels/1/2" }, + { id: "b", name: "Team Call", type: "voice", url: "https://discord.com/channels/3/4" } +]; + +test("formats window status", () => { + assert.equal(formatWindowStatus({ windowId: null }), "Closed"); + assert.equal(formatWindowStatus({ windowId: 10 }), "Open"); +}); + +test("formats zoom percent", () => { + assert.equal(formatZoomPercent(0.9), "90%"); + assert.equal(formatZoomPercent(1), "100%"); +}); + +test("builds grouped popup model", () => { + const model = buildPopupModel({ + shortcuts, + query: "", + activeType: "text", + windowState: { + windowId: 1, + tabId: 2, + bounds: { left: null, top: null, width: 420, height: 900 }, + zoom: 0.9, + lastShortcutId: "a" + } + }); + + assert.equal(model.status, "Open"); + assert.equal(model.zoomLabel, "90%"); + assert.equal(model.activeShortcuts.length, 1); + assert.equal(model.activeShortcuts[0].id, "a"); + assert.equal(model.textCount, 1); + assert.equal(model.voiceCount, 1); +}); + +test("applies search before active type", () => { + const model = buildPopupModel({ + shortcuts, + query: "team", + activeType: "voice", + windowState: { windowId: null, zoom: 1, bounds: { width: 420, height: 900 } } + }); + + assert.equal(model.activeShortcuts.length, 1); + assert.equal(model.activeShortcuts[0].id, "b"); +}); +``` + +- [ ] **Step 2: Run tests and verify the expected failure** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: FAIL because `src/popup/view-model.js` does not exist. + +- [ ] **Step 3: Implement popup view model** + +Create `discord-mini-tabs-extension/src/popup/view-model.js`: + +```js +import { SHORTCUT_TYPES } from "../shared/constants.js"; +import { filterShortcuts, splitShortcutsByType } from "../shared/shortcuts.js"; + +export function formatWindowStatus(windowState) { + return Number.isInteger(windowState?.windowId) ? "Open" : "Closed"; +} + +export function formatZoomPercent(zoom) { + return `${Math.round(Number(zoom) * 100)}%`; +} + +export function buildPopupModel({ shortcuts, query, activeType, windowState }) { + const filtered = filterShortcuts(shortcuts, query); + const grouped = splitShortcutsByType(filtered); + const normalizedType = activeType === SHORTCUT_TYPES.VOICE ? SHORTCUT_TYPES.VOICE : SHORTCUT_TYPES.TEXT; + + return { + status: formatWindowStatus(windowState), + zoomLabel: formatZoomPercent(windowState?.zoom ?? 0.9), + boundsLabel: `${windowState?.bounds?.width ?? 420} x ${windowState?.bounds?.height ?? 900}`, + activeType: normalizedType, + activeShortcuts: grouped[normalizedType], + textCount: grouped.text.length, + voiceCount: grouped.voice.length + }; +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for all tests, including `view-model.test.js`. + +- [ ] **Step 5: Commit view model** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: add popup view model" +``` + +## Task 8: Build The Popup UI And Controller + +**Files:** +- Modify: `discord-mini-tabs-extension/src/popup/popup.html` +- Modify: `discord-mini-tabs-extension/src/popup/popup.css` +- Modify: `discord-mini-tabs-extension/src/popup/popup.js` + +- [ ] **Step 1: Replace popup markup** + +Modify `discord-mini-tabs-extension/src/popup/popup.html`: + +```html + + + + + + Discord Mini Tabs + + + +
+
+
+

Discord Mini Tabs

+

Closed · 420 x 900 · 90%

+
+ +
+ + + +
+ + + +
+ + + +
+ + +
+ +
+ +
+

Add shortcut

+ + + + +
+ + +
+
+ +
+

Window settings

+
+ + + +
+ +
+
+ + + +``` + +- [ ] **Step 2: Replace popup styling** + +Modify `discord-mini-tabs-extension/src/popup/popup.css`: + +```css +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #17181c; + color: #f4f5f8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + width: 380px; + background: #17181c; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 0; + border-radius: 6px; + background: #5865f2; + color: #ffffff; + cursor: pointer; + min-height: 32px; + padding: 0 10px; +} + +button:hover { + background: #6974f5; +} + +button.secondary, +.controls button, +.form-actions button:first-child { + background: #2a2d36; + color: #f4f5f8; +} + +.shell { + display: grid; + gap: 12px; + padding: 14px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 18px; + line-height: 1.2; +} + +h2 { + font-size: 13px; + color: #c9ced9; +} + +p, +.meta, +.shortcut-url, +.field span { + color: #aeb3c2; + font-size: 12px; +} + +.feedback { + border-radius: 6px; + background: #2d2330; + color: #ffd6e7; + padding: 8px 10px; + font-size: 12px; +} + +.controls, +.form-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.field { + display: grid; + gap: 5px; +} + +input, +select { + width: 100%; + border: 1px solid #363a46; + border-radius: 6px; + background: #101116; + color: #f4f5f8; + min-height: 34px; + padding: 7px 9px; +} + +.segments { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.segment { + background: #242731; + color: #c9ced9; +} + +.segment.active { + background: #5865f2; + color: #ffffff; +} + +.shortcut-list { + display: grid; + gap: 8px; + max-height: 230px; + overflow: auto; +} + +.shortcut-card { + display: grid; + gap: 8px; + border: 1px solid #303442; + border-radius: 8px; + background: #1f222b; + padding: 10px; +} + +.shortcut-main { + min-width: 0; +} + +.shortcut-name { + font-size: 14px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.shortcut-url { + margin-top: 2px; + overflow-wrap: anywhere; +} + +.shortcut-actions { + display: flex; + gap: 6px; +} + +.shortcut-actions button { + flex: 1; +} + +.empty { + border: 1px dashed #3b4050; + border-radius: 8px; + color: #aeb3c2; + padding: 18px; + text-align: center; +} + +.panel { + display: grid; + gap: 10px; + border-top: 1px solid #2b2f3a; + padding-top: 12px; +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} +``` + +- [ ] **Step 3: Replace popup controller** + +Modify `discord-mini-tabs-extension/src/popup/popup.js`: + +```js +import { MESSAGE_TYPES, SHORTCUT_TYPES } from "../shared/constants.js"; +import { compactDiscordUrl } from "../shared/url.js"; +import { buildPopupModel } from "./view-model.js"; + +const elements = { + windowMeta: document.querySelector("#windowMeta"), + feedback: document.querySelector("#feedback"), + saveCurrentButton: document.querySelector("#saveCurrentButton"), + focusButton: document.querySelector("#focusButton"), + closeButton: document.querySelector("#closeButton"), + resetButton: document.querySelector("#resetButton"), + searchInput: document.querySelector("#searchInput"), + textTab: document.querySelector("#textTab"), + voiceTab: document.querySelector("#voiceTab"), + textCount: document.querySelector("#textCount"), + voiceCount: document.querySelector("#voiceCount"), + shortcutList: document.querySelector("#shortcutList"), + shortcutForm: document.querySelector("#shortcutForm"), + formTitle: document.querySelector("#formTitle"), + editingId: document.querySelector("#editingId"), + shortcutName: document.querySelector("#shortcutName"), + shortcutUrl: document.querySelector("#shortcutUrl"), + shortcutType: document.querySelector("#shortcutType"), + cancelEditButton: document.querySelector("#cancelEditButton"), + settingsForm: document.querySelector("#settingsForm"), + widthInput: document.querySelector("#widthInput"), + heightInput: document.querySelector("#heightInput"), + zoomInput: document.querySelector("#zoomInput") +}; + +const state = { + shortcuts: [], + windowState: null, + activeType: SHORTCUT_TYPES.TEXT, + query: "" +}; + +async function sendMessage(type, payload = {}) { + const response = await chrome.runtime.sendMessage({ type, payload }); + if (!response?.ok) { + throw new Error(response?.error ?? "Extension request failed."); + } + return response.data; +} + +function showFeedback(message) { + elements.feedback.textContent = message; + elements.feedback.hidden = false; +} + +function clearFeedback() { + elements.feedback.textContent = ""; + elements.feedback.hidden = true; +} + +function setFormMode(shortcut = null) { + elements.editingId.value = shortcut?.id ?? ""; + elements.shortcutName.value = shortcut?.name ?? ""; + elements.shortcutUrl.value = shortcut?.url ?? ""; + elements.shortcutType.value = shortcut?.type ?? state.activeType; + elements.formTitle.textContent = shortcut ? "Edit shortcut" : "Add shortcut"; + elements.cancelEditButton.hidden = !shortcut; +} + +function renderShortcuts(model) { + elements.shortcutList.replaceChildren(); + + if (model.activeShortcuts.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "No shortcuts match this view."; + elements.shortcutList.append(empty); + return; + } + + for (const shortcut of model.activeShortcuts) { + const card = document.createElement("article"); + card.className = "shortcut-card"; + + const main = document.createElement("div"); + main.className = "shortcut-main"; + + const name = document.createElement("div"); + name.className = "shortcut-name"; + name.textContent = shortcut.name; + + const url = document.createElement("div"); + url.className = "shortcut-url"; + url.textContent = compactDiscordUrl(shortcut.url); + + const actions = document.createElement("div"); + actions.className = "shortcut-actions"; + + const openButton = document.createElement("button"); + openButton.type = "button"; + openButton.textContent = "Open"; + openButton.addEventListener("click", () => openShortcut(shortcut.id)); + + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "secondary"; + editButton.textContent = "Edit"; + editButton.addEventListener("click", () => setFormMode(shortcut)); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "secondary"; + deleteButton.textContent = "Delete"; + deleteButton.addEventListener("click", () => deleteShortcut(shortcut.id)); + + main.append(name, url); + actions.append(openButton, editButton, deleteButton); + card.append(main, actions); + elements.shortcutList.append(card); + } +} + +function render() { + const model = buildPopupModel({ + shortcuts: state.shortcuts, + query: state.query, + activeType: state.activeType, + windowState: state.windowState + }); + + elements.windowMeta.textContent = `${model.status} · ${model.boundsLabel} · ${model.zoomLabel}`; + elements.textCount.textContent = String(model.textCount); + elements.voiceCount.textContent = String(model.voiceCount); + elements.textTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.TEXT); + elements.voiceTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.VOICE); + elements.widthInput.value = state.windowState?.bounds?.width ?? 420; + elements.heightInput.value = state.windowState?.bounds?.height ?? 900; + elements.zoomInput.value = Math.round((state.windowState?.zoom ?? 0.9) * 100); + + renderShortcuts(model); +} + +async function refresh() { + const data = await sendMessage(MESSAGE_TYPES.GET_STATE); + state.shortcuts = data.shortcuts; + state.windowState = data.windowState; + render(); +} + +async function runAction(action) { + clearFeedback(); + try { + await action(); + await refresh(); + } catch (error) { + showFeedback(error.message); + } +} + +async function openShortcut(id) { + await runAction(() => sendMessage(MESSAGE_TYPES.OPEN_SHORTCUT, { id })); +} + +async function deleteShortcut(id) { + await runAction(() => sendMessage(MESSAGE_TYPES.DELETE_SHORTCUT, { id })); +} + +elements.searchInput.addEventListener("input", () => { + state.query = elements.searchInput.value; + render(); +}); + +elements.textTab.addEventListener("click", () => { + state.activeType = SHORTCUT_TYPES.TEXT; + render(); +}); + +elements.voiceTab.addEventListener("click", () => { + state.activeType = SHORTCUT_TYPES.VOICE; + render(); +}); + +elements.shortcutForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const payload = { + id: elements.editingId.value || undefined, + name: elements.shortcutName.value, + url: elements.shortcutUrl.value, + type: elements.shortcutType.value + }; + + await runAction(async () => { + if (payload.id) { + await sendMessage(MESSAGE_TYPES.UPDATE_SHORTCUT, payload); + } else { + await sendMessage(MESSAGE_TYPES.CREATE_SHORTCUT, payload); + } + setFormMode(null); + }); +}); + +elements.cancelEditButton.addEventListener("click", () => setFormMode(null)); + +elements.saveCurrentButton.addEventListener("click", async () => { + await runAction(async () => { + const active = await sendMessage(MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB); + setFormMode({ + name: active.suggestedName, + url: active.url, + type: state.activeType + }); + }); +}); + +elements.focusButton.addEventListener("click", () => runAction(() => sendMessage(MESSAGE_TYPES.FOCUS_WINDOW))); +elements.closeButton.addEventListener("click", () => runAction(() => sendMessage(MESSAGE_TYPES.CLOSE_WINDOW))); +elements.resetButton.addEventListener("click", () => runAction(() => sendMessage(MESSAGE_TYPES.RESET_POSITION))); + +elements.settingsForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await runAction(() => + sendMessage(MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS, { + bounds: { + width: Number(elements.widthInput.value), + height: Number(elements.heightInput.value) + }, + zoom: Number(elements.zoomInput.value) / 100 + }) + ); +}); + +refresh().catch((error) => showFeedback(error.message)); +``` + +- [ ] **Step 4: Run tests** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for all tests. + +- [ ] **Step 5: Manually load the extension shell in Chrome** + +Run Chrome manually: + +```text +chrome://extensions +``` + +Enable Developer mode, click Load unpacked, and select: + +```text +C:\Users\RGB\rtk\discord-mini-tabs-extension +``` + +Expected: Chrome accepts the extension with no manifest error. Opening the extension action shows the popup UI. + +- [ ] **Step 6: Commit popup UI** + +```powershell +git add discord-mini-tabs-extension +git commit -m "feat: build discord shortcut popup" +``` + +## Task 9: Add Manual Verification Checklist And Run Final Checks + +**Files:** +- Create: `discord-mini-tabs-extension/MANUAL_TESTS.md` + +- [ ] **Step 1: Create manual verification checklist** + +Create `discord-mini-tabs-extension/MANUAL_TESTS.md`: + +```markdown +# Manual Tests + +## Load Unpacked + +1. Open `chrome://extensions`. +2. Enable Developer mode. +3. Load the `discord-mini-tabs-extension` directory. +4. Confirm Chrome shows no manifest or service worker registration errors. + +## Shortcut Management + +1. Add a text shortcut with `https://discord.com/channels/123/456`. +2. Confirm the shortcut appears in the Text list. +3. Edit the shortcut name. +4. Search for the new name. +5. Delete the shortcut. +6. Confirm the list updates without reopening the popup. + +## Save Current Discord Channel + +1. Open a real Discord channel in a normal Chrome tab. +2. Open the extension popup. +3. Click Save current. +4. Confirm the URL and suggested name populate the form. +5. Save the shortcut. + +## Mini Window + +1. Open a saved text shortcut. +2. Confirm one Chrome popup window opens. +3. Send a Discord chat message in that window. +4. Open another shortcut. +5. Confirm the same popup window is reused. +6. Resize the popup window. +7. Close and reopen a shortcut. +8. Confirm the remembered size is used. + +## Voice URL + +1. Save a Discord voice channel URL as type Voice. +2. Open it from the Voice list. +3. Confirm Discord web displays the voice channel UI. +4. Join or leave voice manually through Discord web. + +## Window Controls + +1. Click Focus and confirm the mini window comes forward. +2. Click Close and confirm the mini window closes. +3. Click Reset position and confirm the next open lets Chrome choose a valid position with the saved size. + +## Settings + +1. Set width to `420`, height to `900`, and zoom to `90`. +2. Open a shortcut. +3. Confirm the mini window size and Discord tab zoom are applied. +4. Change width to `500` and zoom to `100`. +5. Confirm the existing mini window updates. + +## Stability Smoke Test + +1. Switch between several text and voice shortcuts repeatedly. +2. Confirm only one Discord mini window remains open. +3. Close the mini window manually. +4. Open a shortcut again. +5. Confirm the extension recovers without errors. +``` + +- [ ] **Step 2: Run logic tests** + +Run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: PASS for every `node:test` file. + +- [ ] **Step 3: Inspect service worker errors manually** + +In Chrome: + +```text +chrome://extensions +``` + +Open the service worker inspection link for Discord Mini Tabs. + +Expected: no uncaught exceptions after opening popup, adding a shortcut, opening a shortcut, focusing, closing, and changing settings. + +- [ ] **Step 4: Confirm git status only contains intended files** + +Run: + +```powershell +git status --short -- discord-mini-tabs-extension +``` + +Expected: only `discord-mini-tabs-extension` files from this plan appear. + +- [ ] **Step 5: Commit verification docs** + +```powershell +git add discord-mini-tabs-extension/MANUAL_TESTS.md +git commit -m "docs: add discord mini tabs manual tests" +``` + +## Final Verification Gate + +Before claiming the implementation is complete, run: + +```powershell +cd discord-mini-tabs-extension +npm test +``` + +Expected: all Node tests pass. + +Then complete the manual checklist in `discord-mini-tabs-extension/MANUAL_TESTS.md` using Chrome load-unpacked mode. The implementation is not complete until both the automated tests and the manual Chrome checks pass. + +## Spec Coverage Review + +- One reusable Discord popup window: Task 5 and Task 6. +- Default `420x900`, configurable size, remembered bounds: Task 2, Task 5, Task 8. +- Adjustable zoom defaulting to `90%`: Task 2, Task 5, Task 8. +- Text/Voice groups and search: Task 3, Task 7, Task 8. +- Manual URL entry: Task 8. +- Save current Discord channel: Task 6 and Task 8. +- Local storage: Task 4. +- Minimal permissions and MV3 manifest: Task 1. +- No Discord iframe, no content script, no DOM scraping: Task 1 manifest and Task 6 architecture. +- Error recovery for missing window/tab and invalid URLs: Task 2, Task 5, Task 6. +- Automated logic tests and manual stability checks: Tasks 2 through 9. diff --git a/docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md b/docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md new file mode 100644 index 000000000..0b67f52db --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md @@ -0,0 +1,256 @@ +# Discord Mini Tabs Chrome Extension Design + +Date: 2026-05-21 +Status: Approved for implementation planning + +## Summary + +Build a Chrome Manifest V3 extension that opens Discord web channels in a small, reusable Chrome popup window. The extension popup acts as a lightweight control panel for saved Discord text and voice channel shortcuts. Discord itself runs as the official Discord web app in a real Chrome window, so chat, voice UI, login, audio, and permissions remain handled by Discord and Chrome. + +The default mini window size is 420x900. Users can change width, height, zoom, and window position. The extension remembers manual resize and move changes for future opens. + +## Goals + +- Open Discord text and voice channel URLs in one dedicated Chrome popup window. +- Let users chat and interact normally inside Discord web. +- Let users save shortcuts manually or from the current active Discord tab. +- Group shortcuts by Text and Voice, with search and compact window controls. +- Keep performance stable by avoiding embedded Discord, content-script DOM scraping, and multiple Discord instances. +- Store all shortcuts and settings locally with `chrome.storage.local`. + +## Non-Goals + +- Do not clone or reimplement Discord UI. +- Do not embed Discord in an iframe or extension page. +- Do not auto-click Join Voice, mute, deafen, or other Discord controls. +- Do not read Discord messages, inspect Discord DOM, or scrape internal voice state. +- Do not provide real bitrate telemetry. Any voice/audio status shown by the popup is limited to Chrome-level window or tab state if available. +- Do not sync settings across devices in the first version. + +## Architecture + +The extension has three main parts: + +- `manifest.json`: Manifest V3 metadata, permissions, host permissions, popup entry, and service worker. +- Popup UI: user-facing control panel for shortcuts, search, window controls, and settings. +- Background service worker: owns Chrome API interactions for windows, tabs, zoom, and storage. + +The background service worker stores and manages: + +- `windowId`: the current Discord mini popup window id, if it still exists. +- `tabId`: the Discord tab inside the popup window, if it still exists. +- saved shortcuts. +- mini window bounds: `left`, `top`, `width`, `height`. +- default and current zoom. +- last opened shortcut id. + +When the user opens a shortcut, the service worker validates the saved URL, checks whether the stored `windowId` and `tabId` are still valid, and then either: + +- creates a new Chrome popup window with the Discord URL, or +- focuses the existing popup window and updates its existing tab URL. + +The extension should use one Discord mini window at a time. This keeps memory, CPU, and WebRTC/audio surface lower than opening one Discord instance per channel. + +## Permissions + +Use the minimum permissions needed for the agreed behavior: + +- `storage`: save shortcuts, settings, bounds, zoom, and last opened shortcut. +- `tabs`: read/update tab state and set zoom. +- `windows`: create, focus, close, and track the mini popup window. +- `activeTab`: support saving the current Discord channel from the active tab. +- host permissions for `https://discord.com/*`. + +The initial version targets stable Discord web URLs: + +```text +https://discord.com/channels/{serverId}/{channelId} +https://discord.com/channels/@me/{channelId} +``` + +`https://canary.discord.com/*` and `https://ptb.discord.com/*` can be added later if needed, but are not required for the first version. + +## Popup UI + +The popup is a compact command surface, not a Discord renderer. + +Primary sections: + +- Header with extension name and mini window status: `Closed`, `Open`, or `Focused` when known. +- Search input for shortcut name, server label, channel label, or URL. +- Segmented control for `Text` and `Voice`. +- Shortcut list grouped by type. +- Mini window controls: `Focus`, `Close`, `Reset position`. +- Settings controls: `Width`, `Height`, `Zoom`, and `Apply`. +- Add shortcut controls: + - manual URL input and display name. + - `Save current Discord channel` action. + +Each shortcut item should show: + +- display name. +- type: `text` or `voice`. +- a shortened URL or channel identifier. +- actions: `Open`, `Edit`, `Delete`. + +The popup should remain fast and simple. It should not load remote assets, render Discord previews, or maintain a long-running connection. + +## Data Model + +Storage key shape: + +```json +{ + "shortcuts": [ + { + "id": "uuid-or-stable-random-id", + "name": "dev-chat", + "type": "text", + "url": "https://discord.com/channels/123456789012345678/987654321098765432", + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "windowState": { + "windowId": 123, + "tabId": 456, + "bounds": { + "left": 1200, + "top": 80, + "width": 420, + "height": 900 + }, + "zoom": 0.9, + "lastShortcutId": "uuid-or-stable-random-id" + } +} +``` + +The defaults are: + +- width: `420` +- height: `900` +- zoom: `0.9` +- position: near the right edge of the primary display when Chrome allows it. + +Bounds and zoom should be clamped to reasonable values before saving or applying: + +- minimum width: `320` +- minimum height: `480` +- maximum width: `1200` +- maximum height: `1400` +- zoom range: `0.67` to `1.25` + +## User Flows + +### Open Shortcut + +1. User clicks `Open` on a saved Text or Voice shortcut. +2. Popup sends `OPEN_SHORTCUT` to the service worker. +3. Service worker validates the URL. +4. Service worker resolves existing mini window and tab state. +5. If the mini window is alive, focus it and update the tab URL. +6. If the mini window is missing or invalid, create a Chrome popup window using the saved bounds. +7. Apply saved zoom to the Discord tab. +8. Store current `windowId`, `tabId`, and `lastShortcutId`. + +### Save Current Discord Channel + +1. User opens a Discord channel in a normal Chrome tab. +2. User opens the extension popup and clicks `Save current Discord channel`. +3. Popup asks the service worker to inspect the active tab. +4. Service worker accepts only `discord.com/channels/...` URLs. +5. Popup proposes a display name from tab title when possible. +6. User chooses Text or Voice and saves. + +The extension does not need to infer whether a URL is truly text or voice. The user selects the type, and both types are opened through the same Discord URL mechanism. + +### Resize Or Move Mini Window + +1. User manually resizes or moves the Chrome popup window. +2. Background receives `chrome.windows.onBoundsChanged`. +3. Background debounces storage writes. +4. New bounds are saved to `chrome.storage.local`. +5. Next open uses the remembered size and position. + +### Update Size Or Zoom + +1. User changes width, height, or zoom in popup settings. +2. Popup sends `UPDATE_WINDOW_SETTINGS`. +3. Service worker stores the settings. +4. If the mini window exists, service worker applies bounds with `chrome.windows.update`. +5. If the Discord tab exists, service worker applies zoom with `chrome.tabs.setZoom`. + +## Error Handling + +- If stored `windowId` no longer exists, clear it and create a new mini window on the next open. +- If stored `tabId` no longer exists, locate the Discord tab in the mini window or create/update a tab as needed. +- If the mini window is closed manually, clear window state when Chrome reports removal. +- If a saved URL is invalid, block open and show a short error in the popup. +- If `Save current Discord channel` is used outside Discord, show that the active tab is not a Discord channel. +- If Chrome rejects requested bounds because of display limits, fall back to default bounds and let Chrome choose a valid position. +- If zoom cannot be applied, keep the window usable and show a non-blocking popup error. + +## Performance And Stability + +The design favors stability over deep Discord integration: + +- One Discord web instance at a time. +- No content script injection into Discord. +- No DOM polling. +- No background network requests. +- No audio/WebRTC handling by the extension. +- Debounced bounds persistence to avoid excessive storage writes. +- Small local data model stored in `chrome.storage.local`. + +Discord web remains responsible for: + +- login session. +- chat rendering and sending. +- voice channel UI. +- microphone/audio permissions. +- connection quality and reconnect behavior. + +## Testing Plan + +### Logic Tests + +- Validate accepted Discord channel URLs. +- Reject malformed URLs and non-Discord hosts. +- Filter shortcuts by search query. +- Clamp width, height, and zoom settings. +- Recover from missing `windowId` or `tabId`. + +### Manual Chrome Extension Tests + +- Load the extension unpacked in Chrome. +- Add a shortcut manually. +- Save the current Discord channel from an active Discord tab. +- Open a text shortcut and send a test message in Discord web. +- Open a voice shortcut and verify Discord web handles voice UI. +- Switch between shortcuts and confirm only one mini window is reused. +- Resize and move the mini window, close it, then reopen and verify remembered bounds. +- Change zoom and verify it applies to the Discord tab. +- Use Focus, Close, and Reset position. +- Reload the extension and verify stored shortcuts/settings remain. + +### Stability Smoke Test + +- Open the mini window and leave it running for an extended session. +- Switch between several text and voice shortcuts repeatedly. +- Confirm the extension does not open duplicate Discord windows. +- Confirm closing the mini window and reopening recovers cleanly. +- Confirm invalid shortcuts do not crash the popup or service worker. + +## Implementation Scope + +The first implementation should create a standalone extension codebase in a dedicated directory rather than modifying the existing Rust CLI project. A suitable directory name is `discord-mini-tabs-extension`. + +The implementation should be plain, dependency-light TypeScript or JavaScript unless a build step is intentionally chosen during implementation planning. Given the stability goal, the default recommendation is a small MV3 extension with minimal tooling and no runtime framework. + +## Open Follow-Ups For Implementation Planning + +- Choose plain JavaScript versus TypeScript with a small build step. +- Decide exact popup visual style and icon set. +- Decide whether to support `canary.discord.com` and `ptb.discord.com` in the first release. +- Decide whether to include import/export of shortcuts in the first release.