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[™ÎˆLMœÂ‚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}`,
}),
}