This commit is contained in:
abhinav7x94 2026-08-26 04:01:27 +05:30 committed by GitHub
commit cc3deded3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 329 additions and 66 deletions

View file

@ -15,6 +15,8 @@ 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,
MemoryData,
@ -66,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") {
@ -90,36 +94,6 @@ export default defineBackground(() => {
["requestHeaders", "extraHeaders"],
)
// Send message to current active tab.
const sendMessageToCurrentTab = async (message: string) => {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
})
if (tabs.length > 0 && tabs[0].id) {
await browser.tabs.sendMessage(tabs[0].id, {
type: MESSAGE_TYPES.IMPORT_UPDATE,
importedMessage: message,
})
}
}
/**
* Send import completion message
*/
const sendImportDoneMessage = async (totalImported: number) => {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
})
if (tabs.length > 0 && tabs[0].id) {
await browser.tabs.sendMessage(tabs[0].id, {
type: MESSAGE_TYPES.IMPORT_DONE,
totalImported,
})
}
}
/**
* Save memory to supermemory API
*/
@ -246,22 +220,35 @@ export default defineBackground(() => {
* Handle extension messages
*/
browser.runtime.onMessage.addListener(
(message: ExtensionMessage, _sender, sendResponse) => {
(message: ExtensionMessage, sender, sendResponse) => {
// Handle Twitter import request
if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) {
const notifications = createTwitterImportNotifications(
sender.tab?.id,
(tabId, notification) =>
browser.tabs.sendMessage(tabId, notification),
)
const importConfig: TwitterImportConfig = {
isFolderImport: message.isFolderImport,
bookmarkCollectionId: message.bookmarkCollectionId,
selectedProject: message.selectedProject,
onProgress: sendMessageToCurrentTab,
onComplete: sendImportDoneMessage,
onError: async (error: Error) => {
await sendMessageToCurrentTab(`Error: ${error.message}`)
},
onProgress: notifications.onProgress,
onComplete: notifications.onComplete,
onError: notifications.onError,
}
twitterImporter = new TwitterImporter(importConfig)
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

@ -135,12 +135,14 @@ export async function openImportModal() {
const projects = response.success && response.data ? response.data : []
if (projects.length === 0) {
await browser.runtime.sendMessage({
const importResponse = 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`,
})
if (importResponse?.success) {
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
})
}
} else {
await showAllBookmarksProjectModal(projects)
}
@ -163,14 +165,16 @@ async function showAllBookmarksProjectModal(
modal.remove()
try {
await browser.runtime.sendMessage({
const importResponse = 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,
})
if (importResponse?.success) {
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
project_selected: true,
})
}
} catch (error) {
console.error("Error importing all bookmarks:", error)
}
@ -464,7 +468,17 @@ function removeAllTwitterUI() {
/**
* Shows or updates the import progress toast in the bottom-right
*/
function showOrUpdateImportProgressToast(message: string, isComplete = false) {
let importToastDismissTimer: ReturnType<typeof setTimeout> | null = null
function showOrUpdateImportProgressToast(
message: string,
status: "progress" | "success" | "error" = "progress",
) {
if (importToastDismissTimer) {
clearTimeout(importToastDismissTimer)
importToastDismissTimer = null
}
let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
if (!toast) {
@ -538,31 +552,36 @@ function showOrUpdateImportProgressToast(message: string, isComplete = false) {
}
}
// Style for completion
if (isComplete) {
const icon = toast.querySelector(
"#sm-import-progress-icon",
) as HTMLImageElement
const icon = toast.querySelector(
"#sm-import-progress-icon",
) as HTMLImageElement
const textSpan = toast.querySelector(
"#sm-import-progress-text",
) as HTMLSpanElement
if (status === "progress") {
if (icon) {
icon.style.animation = "smPulse 1.5s ease-in-out infinite"
icon.style.opacity = "1"
}
if (textSpan) textSpan.style.color = "#374151"
} else {
if (icon) {
icon.style.animation = "none"
icon.style.opacity = "1"
}
const textSpan = toast.querySelector(
"#sm-import-progress-text",
) as HTMLSpanElement
if (textSpan) {
textSpan.style.color = "#059669"
textSpan.style.color = status === "success" ? "#059669" : "#dc2626"
}
// Auto-dismiss after 4 seconds on completion
setTimeout(() => {
importToastDismissTimer = setTimeout(() => {
const existingToast = document.getElementById(
ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST,
)
if (existingToast) {
dismissToast(existingToast)
}
importToastDismissTimer = null
}, 4000)
}
}
@ -573,15 +592,19 @@ export function updateTwitterImportUI(message: {
totalImported?: number
}) {
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) {
showOrUpdateImportProgressToast(message.importedMessage, false)
showOrUpdateImportProgressToast(message.importedMessage)
}
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
showOrUpdateImportProgressToast(
`✓ Imported ${message.totalImported} tweets!`,
true,
"success",
)
}
if (message.type === MESSAGE_TYPES.IMPORT_ERROR && message.importedMessage) {
showOrUpdateImportProgressToast(message.importedMessage, "error")
}
}
export async function handleTwitterNavigation() {

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,66 @@
import { describe, expect, mock, test } from "bun:test"
import { createTwitterImportController } from "./twitter-import-controller"
function deferred() {
return Promise.withResolvers<void>()
}
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,32 @@
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
const task = (() => {
try {
return Promise.resolve(createImporter(config).startImport())
} catch (error) {
return Promise.reject(error)
}
})()
running = task
void task
.finally(() => {
if (running === task) running = null
})
.catch(() => {})
return task
},
}
}

View file

@ -0,0 +1,92 @@
import { describe, expect, mock, test } from "bun:test"
import { MESSAGE_TYPES } from "./constants"
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 () => {})
const notifications = createTwitterImportNotifications(
activeTabId,
sendMessage,
)
activeTabId = 42
await notifications.onProgress("Imported 10 bookmarks")
await notifications.onError(new Error("rate limited"))
await notifications.onComplete(10)
expect(activeTabId).toBe(42)
expect(sendMessage.mock.calls).toEqual([
[
7,
{
type: MESSAGE_TYPES.IMPORT_UPDATE,
importedMessage: "Imported 10 bookmarks",
},
],
[
7,
{
type: MESSAGE_TYPES.IMPORT_ERROR,
importedMessage: "Error: rate limited",
},
],
[7, { type: MESSAGE_TYPES.IMPORT_DONE, totalImported: 10 }],
])
})
test("does not let tab closure interrupt import callbacks", async () => {
const sendMessage = mock(async () => {
throw new Error("Receiving end does not exist")
})
const notifications = createTwitterImportNotifications(7, sendMessage)
await notifications.onProgress("Retrying")
await notifications.onError(new Error("failed"))
await notifications.onComplete(0)
expect(sendMessage).toHaveBeenCalledTimes(3)
})
test("accepts tab id zero", async () => {
const sendMessage = mock(async () => {})
const notifications = createTwitterImportNotifications(0, sendMessage)
await notifications.onProgress("Starting")
expect(sendMessage).toHaveBeenCalledWith(0, {
type: MESSAGE_TYPES.IMPORT_UPDATE,
importedMessage: "Starting",
})
})
test("skips notifications when the request has no sender tab", async () => {
const sendMessage = mock(async () => {})
const notifications = createTwitterImportNotifications(
undefined,
sendMessage,
)
await notifications.onProgress("Starting")
await notifications.onComplete(0)
expect(sendMessage).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,63 @@
import { MESSAGE_TYPES } from "./constants"
type TwitterImportNotification =
| {
type: typeof MESSAGE_TYPES.IMPORT_UPDATE
importedMessage: string
}
| {
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,
) {
const deliver = async (message: TwitterImportNotification): Promise<void> => {
if (tabId === undefined) return
try {
await sendMessage(tabId, message)
} catch {
// The initiating tab can be closed or navigated while the import keeps
// running. Notification delivery must not cancel the import itself.
}
}
return {
onProgress: (message: string) =>
deliver({
type: MESSAGE_TYPES.IMPORT_UPDATE,
importedMessage: message,
}),
onComplete: (totalImported: number) =>
deliver({
type: MESSAGE_TYPES.IMPORT_DONE,
totalImported,
}),
onError: (error: Error) =>
deliver({
type: MESSAGE_TYPES.IMPORT_ERROR,
importedMessage: `Error: ${error.message}`,
}),
}
}