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 : xëOm¢G§²Úî�Æ­yÚYˆ +Ú[™ÝË›ØØ][Û‹œ]˜[YHOOH‹ÚKØ›ÛÚÛX\šÜÈŠH‚B\™]\›‚‚_B‚‚XÛÛœÝ\™Ù][[Y[�ÈHØÝ[Y[� œ]Y\žTÙ[XÝÜ�[ +‚BH‹˜ÜÜËLMÍ[ÚLœ‹œ‹L]ÝŒ\ œ‹LMžY\ÍKœ‹L[[XYLÛ‹œ‹[ÍÞ[œXËœ‹M� M™YËœ‹L[žM Û œ‹L[Ü] ŒH‹‚JB‚‚]\™Ù][[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‚_JBŸB‚‹ÊŠ‚ˆ +ˆYÈ[ˆ[\Ü��]ÛˆÈH›ÛÚÛX\šÈ›Û\ˆ[[Y[�ˆ +‹Â™�[˜Ý[ÛˆY�]Û•Ñ[[Y[� +[[Y[�ˆS[[Y[� +H‚ZYˆ +[[Y[� œ]Y\žTÙ[XÝÜŠ–Ù]K\Ý\\›Y[[ÜžKX�]Û—HŠJH‚B\™]\›‚‚_B‚‚[ØYÜXÙQÜ›Ý\ÚÑ›Û�Ê +B‚‚XÛÛœÝ�]ÛˆHÜ™X]TØ]™UÙY][[Y[� +\Þ[˜È -async function showAllBookmarksProjectModal( - projects: Array<{ id: string; name: string; containerTag: string }>, -) { - await loadSpaceGroteskFonts() +HOˆ‚BXÛÛœÝ\›H[[Y[� ™Ù]]šX�]Jš™YˆŠB‚BXÛۜݛÛÚÛX\šÐÛÛXÝ[Û’YH\›ËœÜ] +‹ÈŠKœÜ - const modal = createProjectSelectionModal( - projects, - async (selectedProject) => { - modal.remove() +B‚BZYˆ +›ÛÚÛX\šÐÛÛXÝ[Û’Y +H‚BBX]ØZ]ÚÝÑ›Û\”›Ú™XÝÙ[XÝ[Û“[Ù[ +›ÛÚÛX\šÐÛÛXÝ[Û’Y +B‚B_B‚_JB‚‚X�]Û‹œÙ]]šX�]J™]K\Ý\\›Y[[ÜžKX�]Ûˆ‹��YHŠB‚‚Y[[Y[� ˜\[™Ú[ +�]ÛŠB‚Y[[Y[� œÝ[K™›^\™XÝ[ÛˆHœ›ÝÈ‚‚Y[[Y[� œÝ[K˜[YÛ’][\ÈH˜Ù[�\ˆ‚‚Y[[Y[� œÝ[Kš�\ÝYžPÛÛ�[�H˜Ù[�\ˆ‚‚Y[[Y[� œÝ[K™Ø\HŒL‚‚Y[[Y[� œÝ[KœY[™ÈHŒL‚ŸB‚‹ÊŠ‚ˆ +ˆÚÝÜÈH›Ú™XÝÙ[XÝ[Ûˆ[Ù[›Üˆ›Û\ˆ[\Ü�ˆ +‹Â˜\Þ[˜È�[˜Ý[ÛˆÚÝÑ›Û\”›Ú™XÝÙ[XÝ[Û“[Ù[ +›ÛÚÛX\šÐÛÛXÝ[Û’YˆÝš[™ÊH‚X]ØZ]ØYÜXÙQÜ›Ý\ÚÑ›Û�Ê +B‚‚XÛÛœÝ[Ù[HÜ™X]T›Ú™XÝÙ[XÝ[Û“[Ù[ +‚BV×K‚BX\Þ[˜È +Ù[XÝY›Ú™XÝ +HOˆ‚BB[[Ù[ œ™[[Ý™J +B‚‚BB]žH‚BBBX]ØZ]œ›ÝÜÙ\‹œ�[�[YKœÙ[™Y\ÜØYÙJ‚BBBB]\NˆQTÔÐQÑWÕTTË��UÒÒSTÔ•ÐS ‚BBBBZ\Ñ›Û\’[\Ü�ˆ�YK‚BBBBX›ÛÚÛX\šÐÛÛXÝ[Û’Yˆ›ÛÚÛX\šÐÛÛXÝ[Û’Y ‚BBBB\Ù[XÝY›Ú™X݈Ù[XÝY›Ú™XÝ ‚BBB_JB‚BB_HØ]Ú +\œ›ÜŠH‚BBBXÛÛœÛÛK™\œ›ÜŠ‘\œ›Üˆ[\Ü�[™È›ÛÚÛX\šÜΈ‹\œ›ÜŠB‚BB_B‚B_K‚BJ +HOˆ‚BB[[Ù[ œ™[[Ý™J +B‚B_K‚JB‚‚YØÝ[Y[� ˜›ÙK˜\[™Ú[ +[Ù[ +B‚‚]žH‚BXÛۜݙ\ÜÛœÙHH]ØZ]œ›ÝÜÙ\‹œ�[�[YKœÙ[™Y\ÜØYÙJ‚BBXXÝ[ÛŽˆQTÔÐQÑWÕTTË‘‘UÒÔ“Ò‘PÕË‚B_JB‚‚BZYˆ +™\ÜÛœÙKœÝXØÙ\ÜÈ ‰ˆ™\ÜÛœÙK™]JH‚BBXÛۜݛڙXÝÈH™\ÜÛœÙK™]B‚BB]\]S[Ù[Ú]›Ú™XÝÊ[Ù[ ›Ú™XÝÊB‚B_H[ÙH‚BBXÛÛœÛÛK™\œ›ÜŠ‘˜Z[YÈ™]Ú›Ú™XÝΈ‹™\ÜÛœÙK™\œ›ÜŠB‚BB]\]S[Ù[Ú]›Ú™XÝÊ[Ù[ ×JB‚B_B‚_HØ]Ú +\œ›ÜŠH‚BXÛÛœÛÛK™\œ›ÜŠ‘\œ›Üˆ™]Ú[™È›Ú™XÝΈ‹\œ›ÜŠB‚B]\]S[Ù[Ú]›Ú™XÝÊ[Ù[ ×JB‚_BŸB‚‹ÊŠ‚ˆ +ˆ\]\ÈH[Ù[Ú]™]ÚY›Ú™X݈ +‹Â™�[˜Ý[Ûˆ\]S[Ù[Ú]›Ú™XÝÊ‚[[Ù[ˆS[[Y[� ‚\›Ú™XÝΈ\œ˜^OÈYˆÝš[™ÎȘ[YNˆÝš[™ÎÈÛÛ�Z[™\•YΈÝš[™ÈO‹ŠH‚XÛÛœÝÙ[XÝH[Ù[ œ]Y\žTÙ[XÝÜŠˆÜ›Ú™XÝ \Ù[XÝŠH\ÈSÙ[XÝ[[Y[�‚ZYˆ +\Ù[XÝ +H™]\›‚‚‚]Ú[H +Ù[XÝ ˜Ú[™[‹›[™Ýˆ JH‚B\Ù[XÝ œ™[[Ý™PÚ[ +Ù[XÝ ˜Ú[™[–ÌWJB‚_B‚‚ZYˆ +›Ú™XÝË›[™ÝOOH +H‚BXÛۜݛԛڙXÝÓÜ[ÛˆHØÝ[Y[� ˜Ü™X]Q[[Y[� +›Ü[ÛˆŠB‚B[›Ô›Ú™XÝÓÜ[Û‹�˜[YHHˆ‚‚B[›Ô›Ú™XÝÓÜ[Û‹�^ÛÛ�[�H“›È›Ú™XÝÈ]˜Z[X›H‚‚B[›Ô›Ú™XÝÓÜ[Û‹™\ØX›YH�YB‚B\Ù[XÝ ˜\[™Ú[ +›Ô›Ú™XÝÓÜ[ÛŠB‚‚BXÛÛœÝ[\Ü��]ÛˆH[Ù[ œ]Y\žTÙ[XÝÜŠ‚BBH˜�]ÛŽ›\Ý XÚ[‹‚BJH\ÈS�]Û‘[[Y[�‚BZYˆ +[\Ü��]ÛŠH‚BBZ[\Ü��]Û‹™\ØX›YH�YB‚BBZ[\Ü��]Û‹œÝ[K˜ÜÜÕ^H‚BBB\Y[™Îˆ L Mœ‚BBBX›Ü™\Žˆ \ÛÛY™Ø˜J �MK �MK �MK ŒJN‚BBBX›Ü™\‹\˜Y]\Έ Lœ‚BBBX˜XÚÙÜ›Ý[™ˆ™Ø˜J �MK �MK �MK Œ JN‚BBBXÛÛÜŽˆ™Ø˜J �MK �MK �MK ŒÊN‚BBBY›Û� \Ú^™Nˆ M‚BBBY›Û� ]ÙZYÚˆ L ‚BBBXÝ\œÛÜŽˆ›Ý X[ÝÙY‚BBB]˜[œÚ][ÛŽˆ[ ŒœÈX\ÙN‚BBX‚B_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ØÝ[Y[� ˜Ü™X]Q[[Y[� +›Ü[ÛˆŠB‚BB[Ü[Û‹�˜[YHH›Ú™XÝ šY‚BB[Ü[Û‹�^ÛÛ�[�H›Ú™XÝ ›˜[YB‚BB[Ü[Û‹™]\Ù] ˜ÛÛ�Z[™\•YÈH›Ú™XÝ ˜ÛÛ�Z[™\•Y‚BB\Ù[XÝ ˜\[™Ú[ +Ü[ÛŠB‚B_JB‚_BŸB \ 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}`, }), }