mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-08 03:08:21 +00:00
fix(extension): stabilize X import lifecycle
This commit is contained in:
parent
ebf0719c77
commit
8b3456d051
8 changed files with 207 additions and 610 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[™ÎˆLMœÂ‚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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
31
apps/browser-extension/utils/twitter-import-controller.ts
Normal file
31
apps/browser-extension/utils/twitter-import-controller.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue