fix(extension): stabilize X import lifecycle

This commit is contained in:
abhinav7x94 2026-08-16 07:26:45 +05:30
parent ebf0719c77
commit 8b3456d051
8 changed files with 207 additions and 610 deletions

View file

@ -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
}

View file

@ -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)
}
})

View file

@ -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§²Úî<EFBFBD>Æ­yÚYˆ
Ú[™ÝË›ØØ][Û‹œ]˜[YHOOH‹ÚKØ›ÛÚÛX\šÜÈŠH‚B\™]\›‚‚_B‚‚XÛÛœÝ\™Ù][[Y[<EFBFBD>ÈHØÝ[Y[<EFBFBD> œ]Y\žTÙ[XÝÜ<EFBFBD>[
‚BH‹˜ÜÜËLMÍ[ÚLœ‹œ‹L]ÝŒ\ œ‹LMžY\ÍKœ‹L[[XYLÛ‹œ‹[ÍÞ[œXËœ‹M<EFBFBD> M™YËœ‹L[žM Û œ‹L[Ü] ŒH‹‚JB‚‚]\™Ù][[Y[<EFBFBD>Ë™›Ü‘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[<EFBFBD>
HOˆ‚BXY<EFBFBD>]Û•Ñ[[Y[<EFBFBD>
[[Y[<EFBFBD>\ÈS[[Y[<EFBFBD>
B‚_JBŸB‚‹ÊŠ‚ˆ
ˆYÈ[ˆ[\Ü<EFBFBD><EFBFBD>]ÛˆÈH›ÛÚÛX\šÈ›Û\ˆ[[Y[<EFBFBD>ˆ
‹Â™<EFBFBD>[˜Ý[ÛˆY<EFBFBD>]Û•Ñ[[Y[<EFBFBD>
[[Y[<EFBFBD>ˆS[[Y[<EFBFBD>
H‚ZYˆ
[[Y[<EFBFBD> œ]Y\žTÙ[XÝÜŠ–Ù]K\Ý\\›Y[[ÜžKX<EFBFBD>]Û—HŠJH‚B\™]\›‚‚_B‚‚[ØYÜXÙQÜ›Ý\ÚÑ›Û<EFBFBD>Ê
B‚‚XÛÛœÝ<EFBFBD>]ÛˆHÜ™X]TØ]™UÙY][[Y[<EFBFBD>
\Þ[˜È
async function showAllBookmarksProjectModal(
projects: Array<{ id: string; name: string; containerTag: string }>,
) {
await loadSpaceGroteskFonts()
HOˆ‚BXÛÛœÝ\›H[[Y[<EFBFBD> ™Ù]]šX<EFBFBD>]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<EFBFBD>]Û‹œÙ]]šX<EFBFBD>]J™]K\Ý\\›Y[[ÜžKX<EFBFBD>]Ûˆ‹<EFBFBD><EFBFBD>YHŠB‚‚Y[[Y[<EFBFBD> ˜\[™Ú[
<EFBFBD>]ÛŠB‚Y[[Y[<EFBFBD> œÝ[K™›^\™XÝ[ÛˆHœ›ÝÈ‚‚Y[[Y[<EFBFBD> œÝ[K˜[YÛ’][\ÈH˜Ù[<EFBFBD>\ˆ‚‚Y[[Y[<EFBFBD> œÝ[Kš<EFBFBD>\ÝYžPÛÛ<EFBFBD>[<EFBFBD>H˜Ù[<EFBFBD>\ˆ‚‚Y[[Y[<EFBFBD> œÝ[K™Ø\HŒL‚‚Y[[Y[<EFBFBD> œÝ[KœY[™ÈHŒL‚ŸB‚‹ÊŠ‚ˆ
ˆÚÝÜÈH›Ú™XÝÙ[XÝ[Ûˆ[Ù[›Üˆ›Û\ˆ[\Ü<EFBFBD>ˆ
‹Â˜\Þ[˜È<EFBFBD>[˜Ý[ÛˆÚÝÑ›Û\”›Ú™XÝÙ[XÝ[Û“[Ù[
›ÛÚÛX\šÐÛÛXÝ[Û’YˆÝš[™ÊH‚X]ØZ]ØYÜXÙQÜ›Ý\ÚÑ›Û<EFBFBD>Ê
B‚‚XÛÛœÝ[Ù[HÜ™X]T›Ú™XÝÙ[XÝ[Û“[Ù[
‚BV×K‚BX\Þ[˜È
Ù[XÝY›Ú™XÝ
HOˆ‚BB[[Ù[ œ™[[Ý™J
B‚‚BB]žH‚BBBX]ØZ]œ›ÝÜÙ\‹œ<EFBFBD>[<EFBFBD>[YKœÙ[™Y\ÜØYÙJ‚BBBB]\NˆQTÔÐQÑWÕTTË<EFBFBD><EFBFBD>UÒÒSTÔ•ÐS ‚BBBBZ\Ñ›Û\’[\Ü<EFBFBD>ˆ<EFBFBD>YK‚BBBBX›ÛÚÛX\šÐÛÛXÝ[Û’Yˆ›ÛÚÛX\šÐÛÛXÝ[Û’Y ‚BBBB\Ù[XÝY›Ú™X݈Ù[XÝY›Ú™XÝ ‚BBB_JB‚BB_HØ]Ú
\œ›ÜŠH‚BBBXÛÛœÛÛK™\œ›ÜŠ‘\œ›Üˆ[\Ü<EFBFBD>[™È›ÛÚÛX\šÜΈ‹\œ›ÜŠB‚BB_B‚B_K‚BJ
HOˆ‚BB[[Ù[ œ™[[Ý™J
B‚B_K‚JB‚‚YØÝ[Y[<EFBFBD> ˜›ÙK˜\[™Ú[
[Ù[
B‚‚]žH‚BXÛۜݙ\ÜÛœÙHH]ØZ]œ›ÝÜÙ\‹œ<EFBFBD>[<EFBFBD>[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݈
‹Â™<EFBFBD>[˜Ý[Ûˆ\]S[Ù[Ú]›Ú™XÝÊ‚[[Ù[ˆS[[Y[<EFBFBD> ‚\›Ú™XÝΈ\œ˜^OÈYˆÝš[™ÎȘ[YNˆÝš[™ÎÈÛÛ<EFBFBD>Z[™\•YΈÝš[™ÈO‹ŠH‚XÛÛœÝÙ[XÝH[Ù[ œ]Y\žTÙ[XÝÜŠˆÜ›Ú™XÝ \Ù[XÝŠH\ÈSÙ[XÝ[[Y[<EFBFBD>‚ZYˆ
\Ù[XÝ
H™]\›‚‚‚]Ú[H
Ù[XÝ ˜Ú[™[‹›[™Ýˆ JH‚B\Ù[XÝ œ™[[Ý™PÚ[
Ù[XÝ ˜Ú[™[–ÌWJB‚_B‚‚ZYˆ
›Ú™XÝË›[™ÝOOH
H‚BXÛۜݛԛڙXÝÓÜ[ÛˆHØÝ[Y[<EFBFBD> ˜Ü™X]Q[[Y[<EFBFBD>
›Ü[ÛˆŠB‚B[›Ô›Ú™XÝÓÜ[Û‹<EFBFBD>˜[YHHˆ‚‚B[›Ô›Ú™XÝÓÜ[Û‹<EFBFBD>^ÛÛ<EFBFBD>[<EFBFBD>H“›È›Ú™XÝÈ]˜Z[X›H‚‚B[›Ô›Ú™XÝÓÜ[Û‹™\ØX›YH<EFBFBD>YB‚B\Ù[XÝ ˜\[™Ú[
›Ô›Ú™XÝÓÜ[ÛŠB‚‚BXÛÛœÝ[\Ü<EFBFBD><EFBFBD>]ÛˆH[Ù[ œ]Y\žTÙ[XÝÜŠ‚BBH˜<EFBFBD>]ÛŽ›\Ý XÚ[‹‚BJH\ÈS<EFBFBD>]Û‘[[Y[<EFBFBD>‚BZYˆ
[\Ü<EFBFBD><EFBFBD>]ÛŠH‚BBZ[\Ü<EFBFBD><EFBFBD>]Û‹™\ØX›YH<EFBFBD>YB‚BBZ[\Ü<EFBFBD><EFBFBD>]Û‹œÝ[K˜ÜÜÕ^H‚BBB\Y[™Îˆ L Mœ‚BBBX›Ü™\Žˆ \ÛÛY™Ø˜J <EFBFBD>MK <EFBFBD>MK <EFBFBD>MK ŒJN‚BBBX›Ü™\‹\˜Y]\Έ Lœ‚BBBX˜XÚÙÜ›Ý[™ˆ™Ø˜J <EFBFBD>MK <EFBFBD>MK <EFBFBD>MK Œ JN‚BBBXÛÛÜŽˆ™Ø˜J <EFBFBD>MK <EFBFBD>MK <EFBFBD>MK ŒÊN‚BBBY›Û<EFBFBD> \Ú^™Nˆ M‚BBBY›Û<EFBFBD> ]Ù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 = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`
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[<EFBFBD> ˜Ü™X]Q[[Y[<EFBFBD>
›Ü[ÛˆŠB‚BB[Ü[Û‹<EFBFBD>˜[YHH›Ú™XÝ šY‚BB[Ü[Û‹<EFBFBD>^ÛÛ<EFBFBD>[<EFBFBD>H›Ú™XÝ ›˜[YB‚BB[Ü[Û‹™]\Ù] ˜ÛÛ<EFBFBD>Z[™\•YÈH›Ú™XÝ ˜ÛÛ<EFBFBD>Z[™\•Y‚BB\Ù[XÝ ˜\[™Ú[
Ü[ÛŠB‚B_JB‚_BŸB

View file

@ -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",

View file

@ -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<void>((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)
})
})

View file

@ -0,0 +1,31 @@
type TwitterImportRunner = {
startImport: () => Promise<void>
}
export function createTwitterImportController<Config>(
createImporter: (config: Config) => TwitterImportRunner,
) {
let running: Promise<void> | null = null
return {
start(config: Config): Promise<void> | null {
if (running) return null
let task: Promise<void>
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
},
}
}

View file

@ -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",
},
],

View file

@ -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<unknown>
const TWITTER_IMPORT_NOTIFICATION_TYPES = new Set<string>([
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}`,
}),
}