diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index ccf3dce0..39e70752 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -15,6 +15,8 @@ import { type TwitterImportConfig, TwitterImporter, } from "../utils/twitter-import" +import { createTwitterImportController } from "../utils/twitter-import-controller" +import { createTwitterImportNotifications } from "../utils/twitter-import-notifications" import type { ExtensionMessage, MemoryData, @@ -66,7 +68,9 @@ function inferPlatformFromUrl(url?: string): string | undefined { } export default defineBackground(() => { - let twitterImporter: TwitterImporter | null = null + const twitterImports = createTwitterImportController( + (config: TwitterImportConfig) => new TwitterImporter(config), + ) browser.runtime.onInstalled.addListener(async (details) => { if (details.reason === "install" || details.reason === "update") { @@ -90,36 +94,6 @@ export default defineBackground(() => { ["requestHeaders", "extraHeaders"], ) - // Send message to current active tab. - const sendMessageToCurrentTab = async (message: string) => { - const tabs = await browser.tabs.query({ - active: true, - currentWindow: true, - }) - if (tabs.length > 0 && tabs[0].id) { - await browser.tabs.sendMessage(tabs[0].id, { - type: MESSAGE_TYPES.IMPORT_UPDATE, - importedMessage: message, - }) - } - } - - /** - * Send import completion message - */ - const sendImportDoneMessage = async (totalImported: number) => { - const tabs = await browser.tabs.query({ - active: true, - currentWindow: true, - }) - if (tabs.length > 0 && tabs[0].id) { - await browser.tabs.sendMessage(tabs[0].id, { - type: MESSAGE_TYPES.IMPORT_DONE, - totalImported, - }) - } - } - /** * Save memory to supermemory API */ @@ -246,22 +220,35 @@ export default defineBackground(() => { * Handle extension messages */ browser.runtime.onMessage.addListener( - (message: ExtensionMessage, _sender, sendResponse) => { + (message: ExtensionMessage, sender, sendResponse) => { // Handle Twitter import request if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) { + const notifications = createTwitterImportNotifications( + sender.tab?.id, + (tabId, notification) => + browser.tabs.sendMessage(tabId, notification), + ) const importConfig: TwitterImportConfig = { isFolderImport: message.isFolderImport, bookmarkCollectionId: message.bookmarkCollectionId, selectedProject: message.selectedProject, - onProgress: sendMessageToCurrentTab, - onComplete: sendImportDoneMessage, - onError: async (error: Error) => { - await sendMessageToCurrentTab(`Error: ${error.message}`) - }, + onProgress: notifications.onProgress, + onComplete: notifications.onComplete, + onError: notifications.onError, } - twitterImporter = new TwitterImporter(importConfig) - twitterImporter.startImport().catch(console.error) + const importTask = twitterImports.start(importConfig) + if (!importTask) { + const error = "An X bookmark import is already in progress" + void notifications.onError(new Error(error)) + sendResponse({ + success: false, + error, + }) + return true + } + + importTask.catch(console.error) sendResponse({ success: true }) return true } diff --git a/apps/browser-extension/entrypoints/content/index.ts b/apps/browser-extension/entrypoints/content/index.ts index 776d9e9f..4f47b1db 100644 --- a/apps/browser-extension/entrypoints/content/index.ts +++ b/apps/browser-extension/entrypoints/content/index.ts @@ -1,4 +1,5 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants" +import { isTwitterImportNotification } from "../../utils/twitter-import-notifications" import { DOMUtils } from "../../utils/ui-components" import { initializeChatGPT } from "./chatgpt" import { initializeClaude } from "./claude" @@ -28,9 +29,7 @@ export default defineContentScript({ return saveMemory(message.actionSource || "content_script") } else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) { return openImportModal() - } else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) { - updateTwitterImportUI(message) - } else if (message.type === MESSAGE_TYPES.IMPORT_DONE) { + } else if (isTwitterImportNotification(message)) { updateTwitterImportUI(message) } }) diff --git a/apps/browser-extension/entrypoints/content/twitter.ts b/apps/browser-extension/entrypoints/content/twitter.ts index 875f4bab..95ae2e60 100644 --- a/apps/browser-extension/entrypoints/content/twitter.ts +++ b/apps/browser-extension/entrypoints/content/twitter.ts @@ -135,12 +135,14 @@ export async function openImportModal() { const projects = response.success && response.data ? response.data : [] if (projects.length === 0) { - await browser.runtime.sendMessage({ + const importResponse = await browser.runtime.sendMessage({ type: MESSAGE_TYPES.BATCH_IMPORT_ALL, }) - await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, { - source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`, - }) + if (importResponse?.success) { + await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, { + source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`, + }) + } } else { await showAllBookmarksProjectModal(projects) } @@ -163,14 +165,16 @@ async function showAllBookmarksProjectModal( modal.remove() try { - await browser.runtime.sendMessage({ + const importResponse = await browser.runtime.sendMessage({ type: MESSAGE_TYPES.BATCH_IMPORT_ALL, selectedProject: selectedProject, }) - await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, { - source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`, - project_selected: true, - }) + if (importResponse?.success) { + await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, { + source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`, + project_selected: true, + }) + } } catch (error) { console.error("Error importing all bookmarks:", error) } @@ -464,7 +468,17 @@ function removeAllTwitterUI() { /** * Shows or updates the import progress toast in the bottom-right */ -function showOrUpdateImportProgressToast(message: string, isComplete = false) { +let importToastDismissTimer: ReturnType | null = null + +function showOrUpdateImportProgressToast( + message: string, + status: "progress" | "success" | "error" = "progress", +) { + if (importToastDismissTimer) { + clearTimeout(importToastDismissTimer) + importToastDismissTimer = null + } + let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST) if (!toast) { @@ -538,31 +552,36 @@ function showOrUpdateImportProgressToast(message: string, isComplete = false) { } } - // Style for completion - if (isComplete) { - const icon = toast.querySelector( - "#sm-import-progress-icon", - ) as HTMLImageElement + const icon = toast.querySelector( + "#sm-import-progress-icon", + ) as HTMLImageElement + const textSpan = toast.querySelector( + "#sm-import-progress-text", + ) as HTMLSpanElement + + if (status === "progress") { + if (icon) { + icon.style.animation = "smPulse 1.5s ease-in-out infinite" + icon.style.opacity = "1" + } + if (textSpan) textSpan.style.color = "#374151" + } else { if (icon) { icon.style.animation = "none" icon.style.opacity = "1" } - - const textSpan = toast.querySelector( - "#sm-import-progress-text", - ) as HTMLSpanElement if (textSpan) { - textSpan.style.color = "#059669" + textSpan.style.color = status === "success" ? "#059669" : "#dc2626" } - // Auto-dismiss after 4 seconds on completion - setTimeout(() => { + importToastDismissTimer = setTimeout(() => { const existingToast = document.getElementById( ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST, ) if (existingToast) { dismissToast(existingToast) } + importToastDismissTimer = null }, 4000) } } @@ -573,15 +592,19 @@ export function updateTwitterImportUI(message: { totalImported?: number }) { if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) { - showOrUpdateImportProgressToast(message.importedMessage, false) + showOrUpdateImportProgressToast(message.importedMessage) } if (message.type === MESSAGE_TYPES.IMPORT_DONE) { showOrUpdateImportProgressToast( `✓ Imported ${message.totalImported} tweets!`, - true, + "success", ) } + + if (message.type === MESSAGE_TYPES.IMPORT_ERROR && message.importedMessage) { + showOrUpdateImportProgressToast(message.importedMessage, "error") + } } export async function handleTwitterNavigation() { diff --git a/apps/browser-extension/utils/constants.ts b/apps/browser-extension/utils/constants.ts index c5fe8347..0b1cfaf7 100644 --- a/apps/browser-extension/utils/constants.ts +++ b/apps/browser-extension/utils/constants.ts @@ -93,6 +93,7 @@ export const MESSAGE_TYPES = { BATCH_IMPORT_ALL: "sm-batch-import-all", IMPORT_UPDATE: "sm-import-update", IMPORT_DONE: "sm-import-done", + IMPORT_ERROR: "sm-import-error", GET_RELATED_MEMORIES: "sm-get-related-memories", CAPTURE_PROMPT: "sm-capture-prompt", FETCH_PROJECTS: "sm-fetch-projects", diff --git a/apps/browser-extension/utils/twitter-import-controller.test.ts b/apps/browser-extension/utils/twitter-import-controller.test.ts new file mode 100644 index 00000000..1c3e2312 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-controller.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, mock, test } from "bun:test" +import { createTwitterImportController } from "./twitter-import-controller" + +function deferred() { + return Promise.withResolvers() +} + +describe("Twitter import controller", () => { + test("allows only one import until the active run completes", async () => { + const firstRun = deferred() + const secondRun = deferred() + const startImport = mock() + .mockImplementationOnce(() => firstRun.promise) + .mockImplementationOnce(() => secondRun.promise) + const createImporter = mock(() => ({ startImport })) + const controller = createTwitterImportController(createImporter) + + const first = controller.start({ source: "first" }) + const duplicate = controller.start({ source: "duplicate" }) + + expect(first).toBe(firstRun.promise) + expect(duplicate).toBeNull() + expect(createImporter).toHaveBeenCalledTimes(1) + expect(startImport).toHaveBeenCalledTimes(1) + + firstRun.resolve() + await first + + const second = controller.start({ source: "second" }) + expect(second).toBe(secondRun.promise) + expect(createImporter).toHaveBeenCalledTimes(2) + }) + + test("releases the lock when an import rejects", async () => { + const failedRun = deferred() + const recoveredRun = deferred() + const startImport = mock() + .mockImplementationOnce(() => failedRun.promise) + .mockImplementationOnce(() => recoveredRun.promise) + const controller = createTwitterImportController(() => ({ startImport })) + + const failed = controller.start("failed") + failedRun.reject(new Error("network failed")) + await expect(failed).rejects.toThrow("network failed") + + expect(controller.start("recovered")).toBe(recoveredRun.promise) + expect(startImport).toHaveBeenCalledTimes(2) + }) + + test("releases the lock when importer startup throws", async () => { + const recoveredRun = deferred() + const createImporter = mock() + .mockImplementationOnce(() => { + throw new Error("startup failed") + }) + .mockImplementationOnce(() => ({ + startImport: () => recoveredRun.promise, + })) + const controller = createTwitterImportController(createImporter) + + await expect(controller.start("failed")).rejects.toThrow("startup failed") + + expect(controller.start("recovered")).toBe(recoveredRun.promise) + expect(createImporter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/browser-extension/utils/twitter-import-controller.ts b/apps/browser-extension/utils/twitter-import-controller.ts new file mode 100644 index 00000000..c51263ca --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-controller.ts @@ -0,0 +1,32 @@ +type TwitterImportRunner = { + startImport: () => Promise +} + +export function createTwitterImportController( + createImporter: (config: Config) => TwitterImportRunner, +) { + let running: Promise | null = null + + return { + start(config: Config): Promise | null { + if (running) return null + + const task = (() => { + try { + return Promise.resolve(createImporter(config).startImport()) + } catch (error) { + return Promise.reject(error) + } + })() + + running = task + void task + .finally(() => { + if (running === task) running = null + }) + .catch(() => {}) + + return task + }, + } +} diff --git a/apps/browser-extension/utils/twitter-import-notifications.test.ts b/apps/browser-extension/utils/twitter-import-notifications.test.ts new file mode 100644 index 00000000..f2840e60 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-notifications.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, mock, test } from "bun:test" +import { MESSAGE_TYPES } from "./constants" +import { + createTwitterImportNotifications, + isTwitterImportNotification, +} from "./twitter-import-notifications" + +describe("Twitter import notifications", () => { + test("recognizes every notification routed to the content script", () => { + for (const type of [ + MESSAGE_TYPES.IMPORT_UPDATE, + MESSAGE_TYPES.IMPORT_DONE, + MESSAGE_TYPES.IMPORT_ERROR, + ]) { + expect(isTwitterImportNotification({ type })).toBe(true) + } + expect( + isTwitterImportNotification({ type: MESSAGE_TYPES.BATCH_IMPORT_ALL }), + ).toBe(false) + }) + + test("keeps progress, errors, and completion on the initiating tab", async () => { + let activeTabId = 7 + const sendMessage = mock(async () => {}) + const notifications = createTwitterImportNotifications( + activeTabId, + sendMessage, + ) + + activeTabId = 42 + await notifications.onProgress("Imported 10 bookmarks") + await notifications.onError(new Error("rate limited")) + await notifications.onComplete(10) + + expect(activeTabId).toBe(42) + expect(sendMessage.mock.calls).toEqual([ + [ + 7, + { + type: MESSAGE_TYPES.IMPORT_UPDATE, + importedMessage: "Imported 10 bookmarks", + }, + ], + [ + 7, + { + type: MESSAGE_TYPES.IMPORT_ERROR, + importedMessage: "Error: rate limited", + }, + ], + [7, { type: MESSAGE_TYPES.IMPORT_DONE, totalImported: 10 }], + ]) + }) + + test("does not let tab closure interrupt import callbacks", async () => { + const sendMessage = mock(async () => { + throw new Error("Receiving end does not exist") + }) + const notifications = createTwitterImportNotifications(7, sendMessage) + + await notifications.onProgress("Retrying") + await notifications.onError(new Error("failed")) + await notifications.onComplete(0) + + expect(sendMessage).toHaveBeenCalledTimes(3) + }) + + test("accepts tab id zero", async () => { + const sendMessage = mock(async () => {}) + const notifications = createTwitterImportNotifications(0, sendMessage) + + await notifications.onProgress("Starting") + + expect(sendMessage).toHaveBeenCalledWith(0, { + type: MESSAGE_TYPES.IMPORT_UPDATE, + importedMessage: "Starting", + }) + }) + + test("skips notifications when the request has no sender tab", async () => { + const sendMessage = mock(async () => {}) + const notifications = createTwitterImportNotifications( + undefined, + sendMessage, + ) + + await notifications.onProgress("Starting") + await notifications.onComplete(0) + + expect(sendMessage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/browser-extension/utils/twitter-import-notifications.ts b/apps/browser-extension/utils/twitter-import-notifications.ts new file mode 100644 index 00000000..5e8c4898 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-notifications.ts @@ -0,0 +1,63 @@ +import { MESSAGE_TYPES } from "./constants" + +type TwitterImportNotification = + | { + type: typeof MESSAGE_TYPES.IMPORT_UPDATE + importedMessage: string + } + | { + type: typeof MESSAGE_TYPES.IMPORT_DONE + totalImported: number + } + | { + type: typeof MESSAGE_TYPES.IMPORT_ERROR + importedMessage: string + } + +type SendTabMessage = ( + tabId: number, + message: TwitterImportNotification, +) => Promise + +const TWITTER_IMPORT_NOTIFICATION_TYPES = new Set([ + MESSAGE_TYPES.IMPORT_UPDATE, + MESSAGE_TYPES.IMPORT_DONE, + MESSAGE_TYPES.IMPORT_ERROR, +]) + +export function isTwitterImportNotification(message: { type?: string }) { + return !!message.type && TWITTER_IMPORT_NOTIFICATION_TYPES.has(message.type) +} + +export function createTwitterImportNotifications( + tabId: number | undefined, + sendMessage: SendTabMessage, +) { + const deliver = async (message: TwitterImportNotification): Promise => { + if (tabId === undefined) return + try { + await sendMessage(tabId, message) + } catch { + // The initiating tab can be closed or navigated while the import keeps + // running. Notification delivery must not cancel the import itself. + } + } + + return { + onProgress: (message: string) => + deliver({ + type: MESSAGE_TYPES.IMPORT_UPDATE, + importedMessage: message, + }), + onComplete: (totalImported: number) => + deliver({ + type: MESSAGE_TYPES.IMPORT_DONE, + totalImported, + }), + onError: (error: Error) => + deliver({ + type: MESSAGE_TYPES.IMPORT_ERROR, + importedMessage: `Error: ${error.message}`, + }), + } +}