From 63e0d1f705d5dcd8f482b3983d72b2b2ab85a2ba Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 07:12:06 +0530 Subject: [PATCH 1/5] fix(extension): keep X import status on initiating tab --- .../entrypoints/background.ts | 47 +++-------- .../twitter-import-notifications.test.ts | 77 +++++++++++++++++++ .../utils/twitter-import-notifications.ts | 50 ++++++++++++ 3 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 apps/browser-extension/utils/twitter-import-notifications.test.ts create mode 100644 apps/browser-extension/utils/twitter-import-notifications.ts diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index ccf3dce0..dfff5680 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -15,6 +15,7 @@ import { type TwitterImportConfig, TwitterImporter, } from "../utils/twitter-import" +import { createTwitterImportNotifications } from "../utils/twitter-import-notifications" import type { ExtensionMessage, MemoryData, @@ -90,36 +91,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,18 +217,21 @@ 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) @@ -351,3 +325,4 @@ export default defineBackground(() => { }, ) }) + 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..3003dc89 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-notifications.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, mock, test } from "bun:test" +import { MESSAGE_TYPES } from "./constants" +import { createTwitterImportNotifications } from "./twitter-import-notifications" + +describe("Twitter import notifications", () => { + 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_UPDATE, + 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..dc08b8d0 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-notifications.ts @@ -0,0 +1,50 @@ +import { MESSAGE_TYPES } from "./constants" + +type TwitterImportNotification = + | { + type: typeof MESSAGE_TYPES.IMPORT_UPDATE + importedMessage: string + } + | { + type: typeof MESSAGE_TYPES.IMPORT_DONE + totalImported: number + } + +type SendTabMessage = ( + tabId: number, + message: TwitterImportNotification, +) => Promise + +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_UPDATE, + importedMessage: `Error: ${error.message}`, + }), + } +} + From ebf0719c77f00232bd71d74b2e36b314a71373df Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 07:12:46 +0530 Subject: [PATCH 2/5] chore: preserve extension source line endings --- apps/browser-extension/entrypoints/background.ts | 1 - .../browser-extension/utils/twitter-import-notifications.test.ts | 1 - apps/browser-extension/utils/twitter-import-notifications.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index dfff5680..7cacb8a4 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -325,4 +325,3 @@ export default defineBackground(() => { }, ) }) - diff --git a/apps/browser-extension/utils/twitter-import-notifications.test.ts b/apps/browser-extension/utils/twitter-import-notifications.test.ts index 3003dc89..4bd034da 100644 --- a/apps/browser-extension/utils/twitter-import-notifications.test.ts +++ b/apps/browser-extension/utils/twitter-import-notifications.test.ts @@ -74,4 +74,3 @@ describe("Twitter import notifications", () => { expect(sendMessage).not.toHaveBeenCalled() }) }) - diff --git a/apps/browser-extension/utils/twitter-import-notifications.ts b/apps/browser-extension/utils/twitter-import-notifications.ts index dc08b8d0..d2c0d872 100644 --- a/apps/browser-extension/utils/twitter-import-notifications.ts +++ b/apps/browser-extension/utils/twitter-import-notifications.ts @@ -47,4 +47,3 @@ export function createTwitterImportNotifications( }), } } - From 8b3456d05135b7f392d5ecacbe38835809645eb9 Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 07:26:45 +0530 Subject: [PATCH 3/5] fix(extension): stabilize X import lifecycle --- .../entrypoints/background.ts | 19 +- .../entrypoints/content/index.ts | 5 +- .../entrypoints/content/twitter.ts | 653 ++---------------- apps/browser-extension/utils/constants.ts | 1 + .../utils/twitter-import-controller.test.ts | 72 ++ .../utils/twitter-import-controller.ts | 31 + .../twitter-import-notifications.test.ts | 20 +- .../utils/twitter-import-notifications.ts | 16 +- 8 files changed, 207 insertions(+), 610 deletions(-) create mode 100644 apps/browser-extension/utils/twitter-import-controller.test.ts create mode 100644 apps/browser-extension/utils/twitter-import-controller.ts diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index 7cacb8a4..39e70752 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -15,6 +15,7 @@ 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, @@ -67,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") { @@ -234,8 +237,18 @@ export default defineBackground(() => { 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..bc487021 100644 --- a/apps/browser-extension/entrypoints/content/twitter.ts +++ b/apps/browser-extension/entrypoints/content/twitter.ts @@ -132,608 +132,59 @@ export async function openImportModal() { action: MESSAGE_TYPES.FETCH_PROJECTS, }) - const projects = response.success && response.data ? response.data : [] + const projects = response.success && response.data ? response.data : xOmGƭyY +[˛][ۋ]HOOHK؛X\ȊH‚B\]\_BXۜ\][[Y[H[ ]Y\T[Xܐ[ +BHLM[LL]\ LMY\KL[[XYLۋ[[X˜M MY˜L[M L[] HJB]\][[Y[˙ܑXX - if (projects.length === 0) { - 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`, - }) - } else { - await showAllBookmarksProjectModal(projects) - } - } catch (error) { - console.error("Error opening import modal:", error) - await browser.runtime.sendMessage({ - type: MESSAGE_TYPES.BATCH_IMPORT_ALL, - }) - } -} +[[Y[ +HO‚BXY]ە[[Y[ +[[Y[\S[[Y[ +B_JBBʊ +Y[[\ܝ]ۈHX\\[[Y[ +™[[ۈY]ە[[Y[ +[[Y[S[[Y[ +H‚ZY +[[Y[ ]Y\T[X܊]K\\\Y[[ܞKX]ۗHJH‚B\]\_B[YXQܛ\ћ۝ +BXۜ]ۈHܙX]T]UY][[Y[ +\[ -async function showAllBookmarksProjectModal( - projects: Array<{ id: string; name: string; containerTag: string }>, -) { - await loadSpaceGroteskFonts() +HO‚BXۜ\H[[Y[ ]]X]JYBBXۜX\X[ےYH\˜] +ȊK - const modal = createProjectSelectionModal( - projects, - async (selectedProject) => { - modal.remove() +BBZY +X\X[ےY +H‚BBX]Z]ћ\ڙX[X[ۓ[[ +X\X[ےY +BB_B_JBX]ۋ]]X]J]K\\\Y[[ܞKX]ۈYHBY[[Y[ \[[ +]ۊBY[[Y[ [K^\X[ۈHȂY[[Y[ [K[Yے][\H[\Y[[Y[ [K\YP۝[H[\Y[[Y[ [K\HLY[[Y[ [KY[HLBʊ +HڙX[X[ۈ[[܈\[\ܝˆ +˜\[[[ۈћ\ڙX[X[ۓ[[ +X\X[ےY[H‚X]Z]YXQܛ\ћ۝ +BXۜ[[HܙX]TڙX[X[ۓ[[ +BVKBX\[ +[XYڙX +HO‚BB[[[ [[ݙJ +BBB]H‚BBBX]Z]\[[YK[Y\YJ‚BBBB]\NQTQWTTːUSTԕS BBBBZ\ћ\[\ܝYKBBBBXX\X[ےYX\X[ےY BBBB\[XYڙX[XYڙX BBB_JBBB_H] +\܊H‚BBBXۜK\܊\܈[\ܝ[X\Έ\܊BBB_BB_KBJ +HO‚BB[[[ [[ݙJ +BB_KJBY[ K\[[ +[[ +B]H‚BXۜ\ۜHH]Z]\[[YK[Y\YJ‚BBXX[ێQTQWTTˑUґPB_JBBZY +\ۜKX\ \ۜK]JH‚BBXۜڙXH\ۜK]BBB]\]S[[]ڙX[[ ڙXBB_H[H‚BBXۜK\܊Z[Y]ڙXΈ\ۜK\܊BBB]\]S[[]ڙX[[ JBB_B_H] +\܊H‚BXۜK\܊\܈][ڙXΈ\܊BB]\]S[[]ڙX[[ JB_BBʊ +\]\H[[]]YڙXˆ +™[[ۈ\]S[[]ڙX[[[S[[Y[ \ڙXΈ\^OY[N[۝Z[\YΈ[OH‚Xۜ[XH[[ ]Y\T[X܊ڙX \[XH\S[X[[Y[ZY +\[X +H]\][H +[X [[[ JH‚B\[X [[ݙP[ +[X [[WJB_BZY +ڙX˛[OOH +H‚BXۜڙX[ۈH[ ܙX]Q[[Y[ +[ۈBB[ڙX[ۋ[YHHB[ڙX[ۋ^۝[HڙX]Z[XHB[ڙX[ۋ\XYHYBB\[X \[[ +ڙX[ۊBBXۜ[\ܝ]ۈH[[ ]Y\T[X܊BBH]ێ\ X[BJH\S]ۑ[[Y[BZY +[\ܝ]ۊH‚BBZ[\ܝ]ۋ\XYHYBBBZ[\ܝ]ۋ[K^HBBB\Y[Έ L M‚BBBXܙ\ \YؘJ MK MK MK JN‚BBBXܙ\\Y]\Έ L‚BBBXXܛ[ؘJ MK MK MK JN‚BBBX܎ؘJ MK MK MK N‚BBBY۝ \^N M‚BBBY۝ ]ZY L ‚BBBX\܎ X[Y‚BBB][][ێ[ X\N‚BBXB_B_H[H‚B\ڙX˙ܑXX - try { - 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, - }) - } catch (error) { - console.error("Error importing all bookmarks:", error) - } - }, - () => { - modal.remove() - }, - ) - - document.body.appendChild(modal) -} - -/** - * Shows the one-time onboarding toast with progress bar - */ -async function showOnboardingToast() { - await loadSpaceGroteskFonts() - - // Remove any existing toast - const existingToast = document.getElementById( - ELEMENT_IDS.TWITTER_ONBOARDING_TOAST, - ) - if (existingToast) { - existingToast.remove() - } - - const duration = UI_CONFIG.ONBOARDING_TOAST_DURATION - - // Create toast container - const toast = document.createElement("div") - toast.id = ELEMENT_IDS.TWITTER_ONBOARDING_TOAST - toast.style.cssText = ` - position: fixed; - bottom: 20px; - right: 20px; - z-index: 2147483647; - background: #ffffff; - border-radius: 12px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; - font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 14px; - color: #374151; - min-width: 320px; - max-width: 380px; - box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12); - animation: smSlideInUp 0.3s ease-out; - overflow: hidden; - ` - - // Add keyframe animations if not already present - if (!document.getElementById("supermemory-onboarding-toast-styles")) { - const style = document.createElement("style") - style.id = "supermemory-onboarding-toast-styles" - style.textContent = ` - @keyframes smSlideInUp { - from { transform: translateY(100%); opacity: 0; } - to { transform: translateY(0); opacity: 1; } - } - @keyframes smFadeOut { - from { transform: translateY(0); opacity: 1; } - to { transform: translateY(100%); opacity: 0; } - } - @keyframes smProgressGrow { - from { transform: scaleX(0); } - to { transform: scaleX(1); } - } - @keyframes smPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } - } - ` - document.head.appendChild(style) - } - - // Header with icon, text and close button - const header = document.createElement("div") - header.style.cssText = - "display: flex; align-items: flex-start; gap: 12px; position: relative;" - - const iconUrl = browser.runtime.getURL("/new_logo.png") - const icon = document.createElement("img") - icon.src = iconUrl - icon.alt = "Supermemory" - icon.style.cssText = - "width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;" - - const textContainer = document.createElement("div") - textContainer.style.cssText = - "display: flex; flex-direction: column; gap: 4px; flex: 1;" - - const title = document.createElement("span") - title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;" - title.textContent = "Import X/Twitter Bookmarks" - - const description = document.createElement("span") - description.style.cssText = - "font-size: 13px; color: #6b7280; line-height: 1.4;" - description.textContent = - "You can import all your Twitter bookmarks to Supermemory with one click." - - textContainer.appendChild(title) - textContainer.appendChild(description) - - // Close button - const closeButton = document.createElement("button") - closeButton.setAttribute("aria-label", "Close onboarding toast") - closeButton.style.cssText = ` - position: absolute; - top: 0; - right: 0; - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - color: #9ca3af; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: background-color 0.2s; - ` - closeButton.innerHTML = ` - - ` - closeButton.addEventListener("mouseenter", () => { - closeButton.style.backgroundColor = "#f3f4f6" - }) - closeButton.addEventListener("mouseleave", () => { - closeButton.style.backgroundColor = "transparent" - }) - closeButton.addEventListener("click", () => { - dismissToast(toast) - }) - - header.appendChild(icon) - header.appendChild(textContainer) - header.appendChild(closeButton) - - // Action buttons - const buttonsContainer = document.createElement("div") - buttonsContainer.style.cssText = "display: flex; gap: 8px; margin-top: 4px;" - - const importButton = document.createElement("button") - importButton.style.cssText = ` - padding: 8px 16px; - border: none; - border-radius: 8px; - background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%); - color: white; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: opacity 0.2s; - font-family: inherit; - ` - importButton.textContent = "Import now" - importButton.addEventListener("mouseenter", () => { - importButton.style.opacity = "0.9" - }) - importButton.addEventListener("mouseleave", () => { - importButton.style.opacity = "1" - }) - importButton.addEventListener("click", async () => { - dismissToast(toast) - await openImportModal() - }) - - const learnMoreButton = document.createElement("button") - learnMoreButton.style.cssText = ` - padding: 8px 16px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: transparent; - color: #374151; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s; - font-family: inherit; - ` - learnMoreButton.textContent = "Learn more" - learnMoreButton.addEventListener("mouseenter", () => { - learnMoreButton.style.backgroundColor = "#f9fafb" - }) - learnMoreButton.addEventListener("mouseleave", () => { - learnMoreButton.style.backgroundColor = "transparent" - }) - learnMoreButton.addEventListener("click", () => { - window.open("https://docs.supermemory.ai/connectors/twitter", "_blank") - }) - - buttonsContainer.appendChild(importButton) - buttonsContainer.appendChild(learnMoreButton) - - // Progress bar container - const progressBarContainer = document.createElement("div") - progressBarContainer.setAttribute("role", "progressbar") - progressBarContainer.setAttribute("aria-valuemin", "0") - progressBarContainer.setAttribute("aria-valuemax", "100") - progressBarContainer.setAttribute("aria-valuenow", "0") - progressBarContainer.setAttribute( - "aria-label", - "Onboarding toast auto-dismiss progress", - ) - progressBarContainer.style.cssText = ` - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 3px; - background: #e5e7eb; - ` - - const progressBar = document.createElement("div") - progressBar.style.cssText = ` - height: 100%; - background: linear-gradient(90deg, #0ff0d2, #5bd3fb, #1e0ff0); - transform-origin: left; - animation: smProgressGrow ${duration}ms linear forwards; - ` - - // Update progress bar ARIA value as animation progresses - const startTime = Date.now() - const updateProgress = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(100, Math.round((elapsed / duration) * 100)) - progressBarContainer.setAttribute("aria-valuenow", String(progress)) - if (progress < 100) { - requestAnimationFrame(updateProgress) - } - } - requestAnimationFrame(updateProgress) - - progressBarContainer.appendChild(progressBar) - - // Assemble toast - toast.appendChild(header) - toast.appendChild(buttonsContainer) - toast.appendChild(progressBarContainer) - - document.body.appendChild(toast) - - // Auto-dismiss after duration - setTimeout(() => { - if (document.body.contains(toast)) { - dismissToast(toast) - } - }, duration) -} - -/** - * Dismiss the toast with animation - */ -function dismissToast(toast: HTMLElement) { - toast.style.animation = "smFadeOut 0.3s ease-out forwards" - setTimeout(() => { - if (document.body.contains(toast)) { - toast.remove() - } - }, 300) -} - -/** - * Remove all Twitter-specific injected UI - */ -function removeAllTwitterUI() { - // Remove import button (legacy) - if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) { - DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON) - } - // Remove onboarding toast - if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)) { - DOMUtils.removeElement(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST) - } - // Remove import progress toast - if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)) { - DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST) - } - // Remove any folder buttons - document.querySelectorAll("[data-supermemory-button]").forEach((button) => { - button.remove() - }) -} - -/** - * Shows or updates the import progress toast in the bottom-right - */ -function showOrUpdateImportProgressToast(message: string, isComplete = false) { - let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST) - - if (!toast) { - // Ensure animation styles are available - if (!document.getElementById("supermemory-onboarding-toast-styles")) { - const style = document.createElement("style") - style.id = "supermemory-onboarding-toast-styles" - style.textContent = ` - @keyframes smSlideInUp { - from { transform: translateY(100%); opacity: 0; } - to { transform: translateY(0); opacity: 1; } - } - @keyframes smFadeOut { - from { transform: translateY(0); opacity: 1; } - to { transform: translateY(100%); opacity: 0; } - } - @keyframes smPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } - } - ` - document.head.appendChild(style) - } - - // Create new toast - toast = document.createElement("div") - toast.id = ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST - toast.style.cssText = ` - position: fixed; - bottom: 20px; - right: 20px; - z-index: 2147483647; - background: #ffffff; - border-radius: 12px; - padding: 14px 16px; - display: flex; - align-items: center; - gap: 12px; - font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 14px; - color: #374151; - min-width: 280px; - max-width: 360px; - box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12); - animation: smSlideInUp 0.3s ease-out; - ` - - const iconUrl = browser.runtime.getURL("/new_logo.png") - const icon = document.createElement("img") - icon.src = iconUrl - icon.alt = "Supermemory" - icon.id = "sm-import-progress-icon" - icon.style.cssText = - "width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; animation: smPulse 1.5s ease-in-out infinite;" - - const textSpan = document.createElement("span") - textSpan.id = "sm-import-progress-text" - textSpan.style.cssText = "font-weight: 500; flex: 1;" - textSpan.textContent = message - - toast.appendChild(icon) - toast.appendChild(textSpan) - document.body.appendChild(toast) - } else { - // Update existing toast - const textSpan = toast.querySelector( - "#sm-import-progress-text", - ) as HTMLSpanElement - if (textSpan) { - textSpan.textContent = message - } - } - - // Style for completion - if (isComplete) { - const icon = toast.querySelector( - "#sm-import-progress-icon", - ) as HTMLImageElement - 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" - } - - // Auto-dismiss after 4 seconds on completion - setTimeout(() => { - const existingToast = document.getElementById( - ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST, - ) - if (existingToast) { - dismissToast(existingToast) - } - }, 4000) - } -} - -export function updateTwitterImportUI(message: { - type: string - importedMessage?: string - totalImported?: number -}) { - if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) { - showOrUpdateImportProgressToast(message.importedMessage, false) - } - - if (message.type === MESSAGE_TYPES.IMPORT_DONE) { - showOrUpdateImportProgressToast( - `✓ Imported ${message.totalImported} tweets!`, - true, - ) - } -} - -export async function handleTwitterNavigation() { - if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) { - return - } - - if (window.location.pathname === "/i/bookmarks") { - addTwitterImportButtonForFolders() - await handleBookmarksPageLoad() - } else { - removeAllTwitterUI() - } -} - -/** - * Adds import buttons to bookmark folders - */ -function addTwitterImportButtonForFolders() { - if (window.location.pathname !== "/i/bookmarks") { - return - } - - const targetElements = document.querySelectorAll( - ".css-175oi2r.r-1wtj0ep.r-16x9es5.r-1mmae3n.r-o7ynqc.r-6416eg.r-1ny4l3l.r-1loqt21", - ) - - targetElements.forEach((element) => { - addButtonToElement(element as HTMLElement) - }) -} - -/** - * Adds an import button to a bookmark folder element - */ -function addButtonToElement(element: HTMLElement) { - if (element.querySelector("[data-supermemory-button]")) { - return - } - - loadSpaceGroteskFonts() - - const button = createSaveTweetElement(async () => { - const url = element.getAttribute("href") - const bookmarkCollectionId = url?.split("/").pop() - if (bookmarkCollectionId) { - await showFolderProjectSelectionModal(bookmarkCollectionId) - } - }) - - button.setAttribute("data-supermemory-button", "true") - - element.appendChild(button) - element.style.flexDirection = "row" - element.style.alignItems = "center" - element.style.justifyContent = "center" - element.style.gap = "10px" - element.style.padding = "10px" -} - -/** - * Shows the project selection modal for folder imports - */ -async function showFolderProjectSelectionModal(bookmarkCollectionId: string) { - await loadSpaceGroteskFonts() - - const modal = createProjectSelectionModal( - [], - async (selectedProject) => { - modal.remove() - - try { - await browser.runtime.sendMessage({ - type: MESSAGE_TYPES.BATCH_IMPORT_ALL, - isFolderImport: true, - bookmarkCollectionId: bookmarkCollectionId, - selectedProject: selectedProject, - }) - } catch (error) { - console.error("Error importing bookmarks:", error) - } - }, - () => { - modal.remove() - }, - ) - - document.body.appendChild(modal) - - try { - const response = await browser.runtime.sendMessage({ - action: MESSAGE_TYPES.FETCH_PROJECTS, - }) - - if (response.success && response.data) { - const projects = response.data - updateModalWithProjects(modal, projects) - } else { - console.error("Failed to fetch projects:", response.error) - updateModalWithProjects(modal, []) - } - } catch (error) { - console.error("Error fetching projects:", error) - updateModalWithProjects(modal, []) - } -} - -/** - * Updates the modal with fetched projects - */ -function updateModalWithProjects( - modal: HTMLElement, - projects: Array<{ id: string; name: string; containerTag: string }>, -) { - const select = modal.querySelector("#project-select") as HTMLSelectElement - if (!select) return - - while (select.children.length > 1) { - select.removeChild(select.children[1]) - } - - if (projects.length === 0) { - const noProjectsOption = document.createElement("option") - noProjectsOption.value = "" - noProjectsOption.textContent = "No projects available" - noProjectsOption.disabled = true - select.appendChild(noProjectsOption) - - const importButton = modal.querySelector( - "button:last-child", - ) as HTMLButtonElement - if (importButton) { - importButton.disabled = true - importButton.style.cssText = ` - padding: 10px 16px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - background: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.3); - font-size: 14px; - font-weight: 500; - cursor: not-allowed; - transition: all 0.2s ease; - ` - } - } else { - projects.forEach((project) => { - const option = document.createElement("option") - option.value = project.id - option.textContent = project.name - option.dataset.containerTag = project.containerTag - select.appendChild(option) - }) - } -} +ڙX +HO‚BBXۜ[ۈH[ ܙX]Q[[Y[ +[ۈBBB[[ۋ[YHHڙX YBB[[ۋ^۝[HڙX [YBBB[[ۋ]\] ۝Z[\YHڙX ۝Z[\Y‚BB\[X \[[ +[ۊBB_JB_BB \ No newline at end of file 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..ccd27e79 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-controller.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, mock, test } from "bun:test" +import { createTwitterImportController } from "./twitter-import-controller" + +function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + return { promise, reject, resolve } +} + +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..6aaee80c --- /dev/null +++ b/apps/browser-extension/utils/twitter-import-controller.ts @@ -0,0 +1,31 @@ +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 + + let task: Promise + try { + task = Promise.resolve(createImporter(config).startImport()) + } catch (error) { + task = 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 index 4bd034da..f2840e60 100644 --- a/apps/browser-extension/utils/twitter-import-notifications.test.ts +++ b/apps/browser-extension/utils/twitter-import-notifications.test.ts @@ -1,8 +1,24 @@ import { describe, expect, mock, test } from "bun:test" import { MESSAGE_TYPES } from "./constants" -import { createTwitterImportNotifications } from "./twitter-import-notifications" +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 () => {}) @@ -28,7 +44,7 @@ describe("Twitter import notifications", () => { [ 7, { - type: MESSAGE_TYPES.IMPORT_UPDATE, + type: MESSAGE_TYPES.IMPORT_ERROR, importedMessage: "Error: rate limited", }, ], diff --git a/apps/browser-extension/utils/twitter-import-notifications.ts b/apps/browser-extension/utils/twitter-import-notifications.ts index d2c0d872..5e8c4898 100644 --- a/apps/browser-extension/utils/twitter-import-notifications.ts +++ b/apps/browser-extension/utils/twitter-import-notifications.ts @@ -9,12 +9,26 @@ type TwitterImportNotification = 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, @@ -42,7 +56,7 @@ export function createTwitterImportNotifications( }), onError: (error: Error) => deliver({ - type: MESSAGE_TYPES.IMPORT_UPDATE, + type: MESSAGE_TYPES.IMPORT_ERROR, importedMessage: `Error: ${error.message}`, }), } From 73014808306e973f7b9d3945a4454ac718c3ef0e Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 07:27:34 +0530 Subject: [PATCH 4/5] fix(extension): restore complete X import UI source --- .../entrypoints/content/twitter.ts | 676 ++++++++++++++++-- 1 file changed, 624 insertions(+), 52 deletions(-) diff --git a/apps/browser-extension/entrypoints/content/twitter.ts b/apps/browser-extension/entrypoints/content/twitter.ts index bc487021..95ae2e60 100644 --- a/apps/browser-extension/entrypoints/content/twitter.ts +++ b/apps/browser-extension/entrypoints/content/twitter.ts @@ -132,59 +132,631 @@ export async function openImportModal() { action: MESSAGE_TYPES.FETCH_PROJECTS, }) - const projects = response.success && response.data ? response.data : xOmGƭyY -[˛][ۋ]HOOHK؛X\ȊH‚B\]\_BXۜ\][[Y[H[ ]Y\T[Xܐ[ -BHLM[LL]\ LMY\KL[[XYLۋ[[X˜M MY˜L[M L[] HJB]\][[Y[˙ܑXX + const projects = response.success && response.data ? response.data : [] -[[Y[ -HO‚BXY]ە[[Y[ -[[Y[\S[[Y[ -B_JBBʊ -Y[[\ܝ]ۈHX\\[[Y[ -™[[ۈY]ە[[Y[ -[[Y[S[[Y[ -H‚ZY -[[Y[ ]Y\T[X܊]K\\\Y[[ܞKX]ۗHJH‚B\]\_B[YXQܛ\ћ۝ -BXۜ]ۈHܙX]T]UY][[Y[ -\[ + if (projects.length === 0) { + const importResponse = await browser.runtime.sendMessage({ + type: MESSAGE_TYPES.BATCH_IMPORT_ALL, + }) + if (importResponse?.success) { + await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, { + source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`, + }) + } + } else { + await showAllBookmarksProjectModal(projects) + } + } catch (error) { + console.error("Error opening import modal:", error) + await browser.runtime.sendMessage({ + type: MESSAGE_TYPES.BATCH_IMPORT_ALL, + }) + } +} -HO‚BXۜ\H[[Y[ ]]X]JYBBXۜX\X[ےYH\˜] -ȊK +async function showAllBookmarksProjectModal( + projects: Array<{ id: string; name: string; containerTag: string }>, +) { + await loadSpaceGroteskFonts() -BBZY -X\X[ےY -H‚BBX]Z]ћ\ڙX[X[ۓ[[ -X\X[ےY -BB_B_JBX]ۋ]]X]J]K\\\Y[[ܞKX]ۈYHBY[[Y[ \[[ -]ۊBY[[Y[ [K^\X[ۈHȂY[[Y[ [K[Yے][\H[\Y[[Y[ [K\YP۝[H[\Y[[Y[ [K\HLY[[Y[ [KY[HLBʊ -HڙX[X[ۈ[[܈\[\ܝˆ -˜\[[[ۈћ\ڙX[X[ۓ[[ -X\X[ےY[H‚X]Z]YXQܛ\ћ۝ -BXۜ[[HܙX]TڙX[X[ۓ[[ -BVKBX\[ -[XYڙX -HO‚BB[[[ [[ݙJ -BBB]H‚BBBX]Z]\[[YK[Y\YJ‚BBBB]\NQTQWTTːUSTԕS BBBBZ\ћ\[\ܝYKBBBBXX\X[ےYX\X[ےY BBBB\[XYڙX[XYڙX BBB_JBBB_H] -\܊H‚BBBXۜK\܊\܈[\ܝ[X\Έ\܊BBB_BB_KBJ -HO‚BB[[[ [[ݙJ -BB_KJBY[ K\[[ -[[ -B]H‚BXۜ\ۜHH]Z]\[[YK[Y\YJ‚BBXX[ێQTQWTTˑUґPB_JBBZY -\ۜKX\ \ۜK]JH‚BBXۜڙXH\ۜK]BBB]\]S[[]ڙX[[ ڙXBB_H[H‚BBXۜK\܊Z[Y]ڙXΈ\ۜK\܊BBB]\]S[[]ڙX[[ JBB_B_H] -\܊H‚BXۜK\܊\܈][ڙXΈ\܊BB]\]S[[]ڙX[[ JB_BBʊ -\]\H[[]]YڙXˆ -™[[ۈ\]S[[]ڙX[[[S[[Y[ \ڙXΈ\^OY[N[۝Z[\YΈ[OH‚Xۜ[XH[[ ]Y\T[X܊ڙX \[XH\S[X[[Y[ZY -\[X -H]\][H -[X [[[ JH‚B\[X [[ݙP[ -[X [[WJB_BZY -ڙX˛[OOH -H‚BXۜڙX[ۈH[ ܙX]Q[[Y[ -[ۈBB[ڙX[ۋ[YHHB[ڙX[ۋ^۝[HڙX]Z[XHB[ڙX[ۋ\XYHYBB\[X \[[ -ڙX[ۊBBXۜ[\ܝ]ۈH[[ ]Y\T[X܊BBH]ێ\ X[BJH\S]ۑ[[Y[BZY -[\ܝ]ۊH‚BBZ[\ܝ]ۋ\XYHYBBBZ[\ܝ]ۋ[K^HBBB\Y[Έ L M‚BBBXܙ\ \YؘJ MK MK MK JN‚BBBXܙ\\Y]\Έ L‚BBBXXܛ[ؘJ MK MK MK JN‚BBBX܎ؘJ MK MK MK N‚BBBY۝ \^N M‚BBBY۝ ]ZY L ‚BBBX\܎ X[Y‚BBB][][ێ[ X\N‚BBXB_B_H[H‚B\ڙX˙ܑXX + const modal = createProjectSelectionModal( + projects, + async (selectedProject) => { + modal.remove() -ڙX -HO‚BBXۜ[ۈH[ ܙX]Q[[Y[ -[ۈBBB[[ۋ[YHHڙX YBB[[ۋ^۝[HڙX [YBBB[[ۋ]\] ۝Z[\YHڙX ۝Z[\Y‚BB\[X \[[ -[ۊBB_JB_BB \ No newline at end of file + try { + const importResponse = await browser.runtime.sendMessage({ + type: MESSAGE_TYPES.BATCH_IMPORT_ALL, + selectedProject: selectedProject, + }) + 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) + } + }, + () => { + modal.remove() + }, + ) + + document.body.appendChild(modal) +} + +/** + * Shows the one-time onboarding toast with progress bar + */ +async function showOnboardingToast() { + await loadSpaceGroteskFonts() + + // Remove any existing toast + const existingToast = document.getElementById( + ELEMENT_IDS.TWITTER_ONBOARDING_TOAST, + ) + if (existingToast) { + existingToast.remove() + } + + const duration = UI_CONFIG.ONBOARDING_TOAST_DURATION + + // Create toast container + const toast = document.createElement("div") + toast.id = ELEMENT_IDS.TWITTER_ONBOARDING_TOAST + toast.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + z-index: 2147483647; + background: #ffffff; + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: #374151; + min-width: 320px; + max-width: 380px; + box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12); + animation: smSlideInUp 0.3s ease-out; + overflow: hidden; + ` + + // Add keyframe animations if not already present + if (!document.getElementById("supermemory-onboarding-toast-styles")) { + const style = document.createElement("style") + style.id = "supermemory-onboarding-toast-styles" + style.textContent = ` + @keyframes smSlideInUp { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes smFadeOut { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(100%); opacity: 0; } + } + @keyframes smProgressGrow { + from { transform: scaleX(0); } + to { transform: scaleX(1); } + } + @keyframes smPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + ` + document.head.appendChild(style) + } + + // Header with icon, text and close button + const header = document.createElement("div") + header.style.cssText = + "display: flex; align-items: flex-start; gap: 12px; position: relative;" + + const iconUrl = browser.runtime.getURL("/new_logo.png") + const icon = document.createElement("img") + icon.src = iconUrl + icon.alt = "Supermemory" + icon.style.cssText = + "width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;" + + const textContainer = document.createElement("div") + textContainer.style.cssText = + "display: flex; flex-direction: column; gap: 4px; flex: 1;" + + const title = document.createElement("span") + title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;" + title.textContent = "Import X/Twitter Bookmarks" + + const description = document.createElement("span") + description.style.cssText = + "font-size: 13px; color: #6b7280; line-height: 1.4;" + description.textContent = + "You can import all your Twitter bookmarks to Supermemory with one click." + + textContainer.appendChild(title) + textContainer.appendChild(description) + + // Close button + const closeButton = document.createElement("button") + closeButton.setAttribute("aria-label", "Close onboarding toast") + closeButton.style.cssText = ` + position: absolute; + top: 0; + right: 0; + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + color: #9ca3af; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; + ` + closeButton.innerHTML = ` + + ` + closeButton.addEventListener("mouseenter", () => { + closeButton.style.backgroundColor = "#f3f4f6" + }) + closeButton.addEventListener("mouseleave", () => { + closeButton.style.backgroundColor = "transparent" + }) + closeButton.addEventListener("click", () => { + dismissToast(toast) + }) + + header.appendChild(icon) + header.appendChild(textContainer) + header.appendChild(closeButton) + + // Action buttons + const buttonsContainer = document.createElement("div") + buttonsContainer.style.cssText = "display: flex; gap: 8px; margin-top: 4px;" + + const importButton = document.createElement("button") + importButton.style.cssText = ` + padding: 8px 16px; + border: none; + border-radius: 8px; + background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%); + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; + font-family: inherit; + ` + importButton.textContent = "Import now" + importButton.addEventListener("mouseenter", () => { + importButton.style.opacity = "0.9" + }) + importButton.addEventListener("mouseleave", () => { + importButton.style.opacity = "1" + }) + importButton.addEventListener("click", async () => { + dismissToast(toast) + await openImportModal() + }) + + const learnMoreButton = document.createElement("button") + learnMoreButton.style.cssText = ` + padding: 8px 16px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: transparent; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + font-family: inherit; + ` + learnMoreButton.textContent = "Learn more" + learnMoreButton.addEventListener("mouseenter", () => { + learnMoreButton.style.backgroundColor = "#f9fafb" + }) + learnMoreButton.addEventListener("mouseleave", () => { + learnMoreButton.style.backgroundColor = "transparent" + }) + learnMoreButton.addEventListener("click", () => { + window.open("https://docs.supermemory.ai/connectors/twitter", "_blank") + }) + + buttonsContainer.appendChild(importButton) + buttonsContainer.appendChild(learnMoreButton) + + // Progress bar container + const progressBarContainer = document.createElement("div") + progressBarContainer.setAttribute("role", "progressbar") + progressBarContainer.setAttribute("aria-valuemin", "0") + progressBarContainer.setAttribute("aria-valuemax", "100") + progressBarContainer.setAttribute("aria-valuenow", "0") + progressBarContainer.setAttribute( + "aria-label", + "Onboarding toast auto-dismiss progress", + ) + progressBarContainer.style.cssText = ` + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: #e5e7eb; + ` + + const progressBar = document.createElement("div") + progressBar.style.cssText = ` + height: 100%; + background: linear-gradient(90deg, #0ff0d2, #5bd3fb, #1e0ff0); + transform-origin: left; + animation: smProgressGrow ${duration}ms linear forwards; + ` + + // Update progress bar ARIA value as animation progresses + const startTime = Date.now() + const updateProgress = () => { + const elapsed = Date.now() - startTime + const progress = Math.min(100, Math.round((elapsed / duration) * 100)) + progressBarContainer.setAttribute("aria-valuenow", String(progress)) + if (progress < 100) { + requestAnimationFrame(updateProgress) + } + } + requestAnimationFrame(updateProgress) + + progressBarContainer.appendChild(progressBar) + + // Assemble toast + toast.appendChild(header) + toast.appendChild(buttonsContainer) + toast.appendChild(progressBarContainer) + + document.body.appendChild(toast) + + // Auto-dismiss after duration + setTimeout(() => { + if (document.body.contains(toast)) { + dismissToast(toast) + } + }, duration) +} + +/** + * Dismiss the toast with animation + */ +function dismissToast(toast: HTMLElement) { + toast.style.animation = "smFadeOut 0.3s ease-out forwards" + setTimeout(() => { + if (document.body.contains(toast)) { + toast.remove() + } + }, 300) +} + +/** + * Remove all Twitter-specific injected UI + */ +function removeAllTwitterUI() { + // Remove import button (legacy) + if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) { + DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON) + } + // Remove onboarding toast + if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)) { + DOMUtils.removeElement(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST) + } + // Remove import progress toast + if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)) { + DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST) + } + // Remove any folder buttons + document.querySelectorAll("[data-supermemory-button]").forEach((button) => { + button.remove() + }) +} + +/** + * Shows or updates the import progress toast in the bottom-right + */ +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) { + // Ensure animation styles are available + if (!document.getElementById("supermemory-onboarding-toast-styles")) { + const style = document.createElement("style") + style.id = "supermemory-onboarding-toast-styles" + style.textContent = ` + @keyframes smSlideInUp { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes smFadeOut { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(100%); opacity: 0; } + } + @keyframes smPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + ` + document.head.appendChild(style) + } + + // Create new toast + toast = document.createElement("div") + toast.id = ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST + toast.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + z-index: 2147483647; + background: #ffffff; + border-radius: 12px; + padding: 14px 16px; + display: flex; + align-items: center; + gap: 12px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: #374151; + min-width: 280px; + max-width: 360px; + box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12); + animation: smSlideInUp 0.3s ease-out; + ` + + const iconUrl = browser.runtime.getURL("/new_logo.png") + const icon = document.createElement("img") + icon.src = iconUrl + icon.alt = "Supermemory" + icon.id = "sm-import-progress-icon" + icon.style.cssText = + "width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; animation: smPulse 1.5s ease-in-out infinite;" + + const textSpan = document.createElement("span") + textSpan.id = "sm-import-progress-text" + textSpan.style.cssText = "font-weight: 500; flex: 1;" + textSpan.textContent = message + + toast.appendChild(icon) + toast.appendChild(textSpan) + document.body.appendChild(toast) + } else { + // Update existing toast + const textSpan = toast.querySelector( + "#sm-import-progress-text", + ) as HTMLSpanElement + if (textSpan) { + textSpan.textContent = message + } + } + + 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" + } + if (textSpan) { + textSpan.style.color = status === "success" ? "#059669" : "#dc2626" + } + + importToastDismissTimer = setTimeout(() => { + const existingToast = document.getElementById( + ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST, + ) + if (existingToast) { + dismissToast(existingToast) + } + importToastDismissTimer = null + }, 4000) + } +} + +export function updateTwitterImportUI(message: { + type: string + importedMessage?: string + totalImported?: number +}) { + if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) { + showOrUpdateImportProgressToast(message.importedMessage) + } + + if (message.type === MESSAGE_TYPES.IMPORT_DONE) { + showOrUpdateImportProgressToast( + `✓ Imported ${message.totalImported} tweets!`, + "success", + ) + } + + if (message.type === MESSAGE_TYPES.IMPORT_ERROR && message.importedMessage) { + showOrUpdateImportProgressToast(message.importedMessage, "error") + } +} + +export async function handleTwitterNavigation() { + if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) { + return + } + + if (window.location.pathname === "/i/bookmarks") { + addTwitterImportButtonForFolders() + await handleBookmarksPageLoad() + } else { + removeAllTwitterUI() + } +} + +/** + * Adds import buttons to bookmark folders + */ +function addTwitterImportButtonForFolders() { + if (window.location.pathname !== "/i/bookmarks") { + return + } + + const targetElements = document.querySelectorAll( + ".css-175oi2r.r-1wtj0ep.r-16x9es5.r-1mmae3n.r-o7ynqc.r-6416eg.r-1ny4l3l.r-1loqt21", + ) + + targetElements.forEach((element) => { + addButtonToElement(element as HTMLElement) + }) +} + +/** + * Adds an import button to a bookmark folder element + */ +function addButtonToElement(element: HTMLElement) { + if (element.querySelector("[data-supermemory-button]")) { + return + } + + loadSpaceGroteskFonts() + + const button = createSaveTweetElement(async () => { + const url = element.getAttribute("href") + const bookmarkCollectionId = url?.split("/").pop() + if (bookmarkCollectionId) { + await showFolderProjectSelectionModal(bookmarkCollectionId) + } + }) + + button.setAttribute("data-supermemory-button", "true") + + element.appendChild(button) + element.style.flexDirection = "row" + element.style.alignItems = "center" + element.style.justifyContent = "center" + element.style.gap = "10px" + element.style.padding = "10px" +} + +/** + * Shows the project selection modal for folder imports + */ +async function showFolderProjectSelectionModal(bookmarkCollectionId: string) { + await loadSpaceGroteskFonts() + + const modal = createProjectSelectionModal( + [], + async (selectedProject) => { + modal.remove() + + try { + await browser.runtime.sendMessage({ + type: MESSAGE_TYPES.BATCH_IMPORT_ALL, + isFolderImport: true, + bookmarkCollectionId: bookmarkCollectionId, + selectedProject: selectedProject, + }) + } catch (error) { + console.error("Error importing bookmarks:", error) + } + }, + () => { + modal.remove() + }, + ) + + document.body.appendChild(modal) + + try { + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.FETCH_PROJECTS, + }) + + if (response.success && response.data) { + const projects = response.data + updateModalWithProjects(modal, projects) + } else { + console.error("Failed to fetch projects:", response.error) + updateModalWithProjects(modal, []) + } + } catch (error) { + console.error("Error fetching projects:", error) + updateModalWithProjects(modal, []) + } +} + +/** + * Updates the modal with fetched projects + */ +function updateModalWithProjects( + modal: HTMLElement, + projects: Array<{ id: string; name: string; containerTag: string }>, +) { + const select = modal.querySelector("#project-select") as HTMLSelectElement + if (!select) return + + while (select.children.length > 1) { + select.removeChild(select.children[1]) + } + + if (projects.length === 0) { + const noProjectsOption = document.createElement("option") + noProjectsOption.value = "" + noProjectsOption.textContent = "No projects available" + noProjectsOption.disabled = true + select.appendChild(noProjectsOption) + + const importButton = modal.querySelector( + "button:last-child", + ) as HTMLButtonElement + if (importButton) { + importButton.disabled = true + importButton.style.cssText = ` + padding: 10px 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.3); + font-size: 14px; + font-weight: 500; + cursor: not-allowed; + transition: all 0.2s ease; + ` + } + } else { + projects.forEach((project) => { + const option = document.createElement("option") + option.value = project.id + option.textContent = project.name + option.dataset.containerTag = project.containerTag + select.appendChild(option) + }) + } +} From 96f7d22974aea6602d0a149f601f4f2158a74408 Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 15:48:18 +0530 Subject: [PATCH 5/5] style(extension): address X import review feedback --- .../utils/twitter-import-controller.test.ts | 8 +------- .../utils/twitter-import-controller.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/browser-extension/utils/twitter-import-controller.test.ts b/apps/browser-extension/utils/twitter-import-controller.test.ts index ccd27e79..1c3e2312 100644 --- a/apps/browser-extension/utils/twitter-import-controller.test.ts +++ b/apps/browser-extension/utils/twitter-import-controller.test.ts @@ -2,13 +2,7 @@ import { describe, expect, mock, test } from "bun:test" import { createTwitterImportController } from "./twitter-import-controller" function deferred() { - let resolve!: () => void - let reject!: (error: Error) => void - const promise = new Promise((onResolve, onReject) => { - resolve = onResolve - reject = onReject - }) - return { promise, reject, resolve } + return Promise.withResolvers() } describe("Twitter import controller", () => { diff --git a/apps/browser-extension/utils/twitter-import-controller.ts b/apps/browser-extension/utils/twitter-import-controller.ts index 6aaee80c..c51263ca 100644 --- a/apps/browser-extension/utils/twitter-import-controller.ts +++ b/apps/browser-extension/utils/twitter-import-controller.ts @@ -11,12 +11,13 @@ export function createTwitterImportController( start(config: Config): Promise | null { if (running) return null - let task: Promise - try { - task = Promise.resolve(createImporter(config).startImport()) - } catch (error) { - task = Promise.reject(error) - } + const task = (() => { + try { + return Promise.resolve(createImporter(config).startImport()) + } catch (error) { + return Promise.reject(error) + } + })() running = task void task