Merge pull request #395 from supermemoryai/mahesh/browser-extension

feat: browser extension
This commit is contained in:
Mahesh Sanikommu 2025-08-29 18:34:52 -07:00 committed by GitHub
commit db00b0a89e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 4727 additions and 199 deletions

26
apps/browser-extension/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1 @@
## supermemory Browser Extension

View file

@ -0,0 +1,201 @@
import { getDefaultProject, saveMemory, searchMemories } from "../utils/api"
import {
CONTAINER_TAGS,
CONTEXT_MENU_IDS,
MESSAGE_TYPES,
} from "../utils/constants"
import { captureTwitterTokens } from "../utils/twitter-auth"
import {
type TwitterImportConfig,
TwitterImporter,
} from "../utils/twitter-import"
import type {
ExtensionMessage,
MemoryData,
MemoryPayload,
} from "../utils/types"
export default defineBackground(() => {
let twitterImporter: TwitterImporter | null = null
browser.runtime.onInstalled.addListener((details) => {
browser.contextMenus.create({
id: CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY,
title: "Save to supermemory",
contexts: ["selection", "page", "link"],
})
// Open welcome tab on first install
if (details.reason === "install") {
browser.tabs.create({
url: browser.runtime.getURL("/welcome.html"),
})
}
})
// Intercept Twitter requests to capture authentication headers.
browser.webRequest.onBeforeSendHeaders.addListener(
(details) => {
captureTwitterTokens(details)
return {}
},
{ urls: ["*://x.com/*", "*://twitter.com/*"] },
["requestHeaders", "extraHeaders"],
)
// Handle context menu clicks.
browser.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY) {
if (tab?.id) {
try {
await browser.tabs.sendMessage(tab.id, {
action: MESSAGE_TYPES.SAVE_MEMORY,
})
} catch (error) {
console.error("Failed to send message to content script:", error)
}
}
}
})
// 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
*/
const saveMemoryToSupermemory = async (
data: MemoryData,
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
try {
let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT
try {
const defaultProject = await getDefaultProject()
if (defaultProject?.containerTag) {
containerTag = defaultProject.containerTag
}
} catch (error) {
console.warn("Failed to get default project, using fallback:", error)
}
const payload: MemoryPayload = {
containerTags: [containerTag],
content: `${data.highlightedText}\n\n${data.html}\n\n${data?.url}`,
metadata: { sm_source: "consumer" },
}
const responseData = await saveMemory(payload)
return { success: true, data: responseData }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
const getRelatedMemories = async (
data: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
try {
const responseData = await searchMemories(data)
const response = responseData as {
results?: Array<{ memory?: string }>
}
let memories = "";
response.results?.forEach((result, index) => {
memories += `[${index + 1}] ${result.memory} `
})
console.log("Memories:", memories)
return { success: true, data: memories }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
/**
* Handle extension messages
*/
browser.runtime.onMessage.addListener(
(message: ExtensionMessage, _sender, sendResponse) => {
// Handle Twitter import request
if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) {
const importConfig: TwitterImportConfig = {
onProgress: sendMessageToCurrentTab,
onComplete: sendImportDoneMessage,
onError: async (error: Error) => {
await sendMessageToCurrentTab(`Error: ${error.message}`)
},
}
twitterImporter = new TwitterImporter(importConfig)
twitterImporter.startImport().catch(console.error)
sendResponse({ success: true })
return true
}
// Handle regular memory save request
if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
;(async () => {
try {
const result = await saveMemoryToSupermemory(
message.data as MemoryData,
)
sendResponse(result)
} catch (error) {
sendResponse({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
})
}
})()
return true
}
if (message.action === MESSAGE_TYPES.GET_RELATED_MEMORIES) {
;(async () => {
try {
const result = await getRelatedMemories(message.data as string)
sendResponse(result)
} catch (error) {
sendResponse({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
})
}
})()
return true
}
},
)
})

View file

@ -0,0 +1,463 @@
import {
DOMAINS,
ELEMENT_IDS,
MESSAGE_TYPES,
STORAGE_KEYS,
} from "../utils/constants"
import {
createChatGPTInputBarElement,
createSaveTweetElement,
createTwitterImportButton,
createTwitterImportUI,
DOMUtils,
} from "../utils/ui-components"
export default defineContentScript({
matches: ["<all_urls>"],
main() {
let twitterImportUI: HTMLElement | null = null
let isTwitterImportOpen = false
browser.runtime.onMessage.addListener(async (message) => {
if (message.action === MESSAGE_TYPES.SHOW_TOAST) {
DOMUtils.showToast(message.state)
} else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
await saveMemory()
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
updateTwitterImportUI(message)
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
updateTwitterImportUI(message)
}
})
const observeForMemoriesDialog = () => {
const observer = new MutationObserver(() => {
if (DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
addSupermemoryButtonToMemoriesDialog()
addSaveChatGPTElementBeforeComposerBtn()
}
if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
addTwitterImportButton()
//addSaveTweetElement();
}
})
observer.observe(document.body, {
childList: true,
subtree: true,
})
if (
window.location.hostname === "chatgpt.com" ||
window.location.hostname === "chat.openai.com"
) {
addSupermemoryButtonToMemoriesDialog()
addSaveChatGPTElementBeforeComposerBtn()
}
if (
window.location.hostname === "x.com" ||
window.location.hostname === "twitter.com"
) {
addTwitterImportButton()
//addSaveTweetElement();
}
}
if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
setTimeout(() => {
addTwitterImportButton() // Wait 2 seconds for page to load
//addSaveTweetElement();
}, 2000)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", observeForMemoriesDialog)
} else {
observeForMemoriesDialog()
}
async function saveMemory() {
try {
DOMUtils.showToast("loading")
const highlightedText = window.getSelection()?.toString() || ""
const url = window.location.href
const html = document.documentElement.outerHTML
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.SAVE_MEMORY,
data: {
html,
highlightedText,
url,
},
})
console.log("Response from enxtension:", response)
if (response.success) {
DOMUtils.showToast("success")
} else {
DOMUtils.showToast("error")
}
} catch (error) {
console.error("Error saving memory:", error)
DOMUtils.showToast("error")
}
}
async function getRelatedMemories() {
try {
const userQuery =
document.getElementById("prompt-textarea")?.textContent || ""
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
data: userQuery,
})
if (response.success && response.data) {
const promptElement = document.getElementById("prompt-textarea")
if (promptElement) {
const currentContent = promptElement.innerHTML
promptElement.innerHTML = `${currentContent}<br>Supermemories: ${response.data}`
}
}
} catch (error) {
console.error("Error getting related memories:", error)
}
}
function addSupermemoryButtonToMemoriesDialog() {
const dialogs = document.querySelectorAll('[role="dialog"]')
let memoriesDialog: HTMLElement | null = null
for (const dialog of dialogs) {
const headerText = dialog.querySelector("h2")
if (headerText?.textContent?.includes("Saved memories")) {
memoriesDialog = dialog as HTMLElement
break
}
}
if (!memoriesDialog) return
if (memoriesDialog.querySelector("#supermemory-save-button")) return
const deleteAllContainer = memoriesDialog.querySelector(
".mt-5.flex.justify-end",
)
if (!deleteAllContainer) return
const supermemoryButton = document.createElement("button")
supermemoryButton.id = "supermemory-save-button"
supermemoryButton.className = "btn relative btn-primary-outline mr-2"
const iconUrl = browser.runtime.getURL("/icon-16.png")
supermemoryButton.innerHTML = `
<div class="flex items-center justify-center gap-2">
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
Save to supermemory
</div>
`
supermemoryButton.style.cssText = `
background: #1C2026 !important;
color: white !important;
border: 1px solid #1C2026 !important;
border-radius: 9999px !important;
padding: 10px 16px !important;
font-weight: 500 !important;
font-size: 14px !important;
margin-right: 8px !important;
cursor: pointer !important;
`
supermemoryButton.addEventListener("mouseenter", () => {
supermemoryButton.style.backgroundColor = "#2B2E33"
})
supermemoryButton.addEventListener("mouseleave", () => {
supermemoryButton.style.backgroundColor = "#1C2026"
})
supermemoryButton.addEventListener("click", async () => {
await saveMemoriesToSupermemory()
})
deleteAllContainer.insertBefore(
supermemoryButton,
deleteAllContainer.firstChild,
)
}
async function saveMemoriesToSupermemory() {
try {
DOMUtils.showToast("loading")
const memoriesTable = document.querySelector(
'[role="dialog"] table tbody',
)
if (!memoriesTable) {
DOMUtils.showToast("error")
return
}
const memoryRows = memoriesTable.querySelectorAll("tr")
const memories: string[] = []
memoryRows.forEach((row) => {
const memoryCell = row.querySelector("td .py-2.whitespace-pre-wrap")
if (memoryCell?.textContent) {
memories.push(memoryCell.textContent.trim())
}
})
console.log("Memories:", memories)
if (memories.length === 0) {
DOMUtils.showToast("error")
return
}
const combinedContent = `ChatGPT Saved Memories:\n\n${memories.map((memory, index) => `${index + 1}. ${memory}`).join("\n\n")}`
const response = await browser.runtime.sendMessage({
action: "saveMemory",
data: {
html: combinedContent,
},
})
if (response.success) {
DOMUtils.showToast("success")
} else {
DOMUtils.showToast("error")
}
} catch (error) {
console.error("Error saving memories to supermemory:", error)
DOMUtils.showToast("error")
}
}
function addTwitterImportButton() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
return
}
const button = createTwitterImportButton(() => {
showTwitterImportUI()
})
document.body.appendChild(button)
}
function showTwitterImportUI() {
if (twitterImportUI) {
twitterImportUI.remove()
}
isTwitterImportOpen = true
// Check if user is authenticated
browser.storage.local.get([STORAGE_KEYS.BEARER_TOKEN], (result) => {
const isAuthenticated = !!result[STORAGE_KEYS.BEARER_TOKEN]
twitterImportUI = createTwitterImportUI(
hideTwitterImportUI,
async () => {
try {
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
})
} catch (error) {
console.error("Error starting import:", error)
}
},
isAuthenticated,
)
document.body.appendChild(twitterImportUI)
})
}
function hideTwitterImportUI() {
if (twitterImportUI) {
twitterImportUI.remove()
twitterImportUI = null
}
isTwitterImportOpen = false
}
function updateTwitterImportUI(message: {
type: string
importedMessage?: string
totalImported?: number
}) {
if (!isTwitterImportOpen || !twitterImportUI) return
const statusDiv = twitterImportUI.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_STATUS}`)
const button = twitterImportUI.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`)
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
if (statusDiv) {
statusDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; color: #92400e; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 8px 12px; font-size: 13px;">
<div style="width: 12px; height: 12px; border: 2px solid #f59e0b; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
<span>${message.importedMessage}</span>
</div>
`
}
if (button) {
;(button as HTMLButtonElement).disabled = true
;(button as HTMLButtonElement).textContent = "Importing..."
}
}
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
if (statusDiv) {
statusDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; color: #0369a1; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 8px; padding: 8px 12px; font-size: 13px;">
<span style="color: #059669;"></span>
<span>Successfully imported ${message.totalImported} tweets!</span>
</div>
`
}
setTimeout(() => {
hideTwitterImportUI()
}, 3000)
}
}
function addSaveChatGPTElementBeforeComposerBtn() {
if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
return
}
const composerButtons = document.querySelectorAll("button.composer-btn")
composerButtons.forEach((button) => {
if (button.hasAttribute("data-supermemory-icon-added-before")) {
return
}
const parent = button.parentElement
if (!parent) return
const parentSiblings = parent.parentElement?.children
if (!parentSiblings) return
let hasSpeechButtonSibling = false
for (const sibling of parentSiblings) {
if (
sibling.getAttribute("data-testid") ===
"composer-speech-button-container"
) {
hasSpeechButtonSibling = true
break
}
}
if (!hasSpeechButtonSibling) return
const grandParent = parent.parentElement
if (!grandParent) return
const existingIcon = grandParent.querySelector(
`#${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer`,
)
if (existingIcon) {
button.setAttribute("data-supermemory-icon-added-before", "true")
return
}
const saveChatGPTElement = createChatGPTInputBarElement(async () => {
await getRelatedMemories()
})
saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
button.setAttribute("data-supermemory-icon-added-before", "true")
grandParent.insertBefore(saveChatGPTElement, parent)
})
}
// TODO: Add Tweet Capture Functionality
function _addSaveTweetElement() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
const targetDivs = document.querySelectorAll(
"div.css-175oi2r.r-18u37iz.r-1h0z5md.r-1wron08",
)
targetDivs.forEach((targetDiv) => {
if (targetDiv.hasAttribute("data-supermemory-icon-added")) {
return
}
const previousElement = targetDiv.previousElementSibling
if (previousElement?.id?.startsWith(ELEMENT_IDS.SAVE_TWEET_ELEMENT)) {
targetDiv.setAttribute("data-supermemory-icon-added", "true")
return
}
const saveTweetElement = createSaveTweetElement(async () => {
await saveMemory()
})
saveTweetElement.id = `${ELEMENT_IDS.SAVE_TWEET_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
targetDiv.setAttribute("data-supermemory-icon-added", "true")
targetDiv.parentNode?.insertBefore(saveTweetElement, targetDiv)
})
}
document.addEventListener("keydown", async (event) => {
if (
(event.ctrlKey || event.metaKey) &&
event.shiftKey &&
event.key === "m"
) {
event.preventDefault()
await saveMemory()
}
})
window.addEventListener("message", (event) => {
if (event.source !== window) {
return
}
const bearerToken = event.data.token
if (bearerToken) {
if (
!(
window.location.hostname === "localhost" ||
window.location.hostname === "supermemory.ai" ||
window.location.hostname === "app.supermemory.ai"
)
) {
console.log(
"Bearer token is only allowed to be used on localhost or supermemory.ai",
)
return
}
chrome.storage.local.set({
[STORAGE_KEYS.BEARER_TOKEN]: bearerToken,
}, () => {})
}
})
},
})

View file

@ -0,0 +1,42 @@
@import "tailwindcss";
/* Custom Font Definitions */
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype");
}

View file

@ -0,0 +1,404 @@
import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import "./App.css"
import { STORAGE_KEYS } from "../../utils/constants"
import {
useDefaultProject,
useProjects,
useSetDefaultProject,
} from "../../utils/query-hooks"
import type { Project } from "../../utils/types"
function App() {
const [userSignedIn, setUserSignedIn] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(true)
const [showProjectSelector, setShowProjectSelector] = useState<boolean>(false)
const [currentUrl, setCurrentUrl] = useState<string>("")
const [currentTitle, setCurrentTitle] = useState<string>("")
const [saving, setSaving] = useState<boolean>(false)
const [activeTab, setActiveTab] = useState<"save" | "imports">("save")
const queryClient = useQueryClient()
const { data: projects = [], isLoading: loadingProjects } = useProjects({
enabled: userSignedIn,
})
const { data: defaultProject } = useDefaultProject({
enabled: userSignedIn,
})
const setDefaultProjectMutation = useSetDefaultProject()
useEffect(() => {
const checkAuthStatus = async () => {
try {
const result = await chrome.storage.local.get([
STORAGE_KEYS.BEARER_TOKEN,
])
const isSignedIn = !!result[STORAGE_KEYS.BEARER_TOKEN]
setUserSignedIn(isSignedIn)
} catch (error) {
console.error("Error checking auth status:", error)
setUserSignedIn(false)
} finally {
setLoading(false)
}
}
const getCurrentTab = async () => {
try {
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
})
if (tabs.length > 0 && tabs[0].url && tabs[0].title) {
setCurrentUrl(tabs[0].url)
setCurrentTitle(tabs[0].title)
}
} catch (error) {
console.error("Error getting current tab:", error)
}
}
checkAuthStatus()
getCurrentTab()
}, [])
const handleProjectSelect = (project: Project) => {
setDefaultProjectMutation.mutate(project, {
onSuccess: () => {
setShowProjectSelector(false)
},
onError: (error) => {
console.error("Error setting default project:", error)
},
})
}
const handleShowProjectSelector = () => {
setShowProjectSelector(true)
}
useEffect(() => {
if (!defaultProject && projects.length > 0) {
const firstProject = projects[0]
setDefaultProjectMutation.mutate(firstProject)
}
}, [defaultProject, projects, setDefaultProjectMutation])
const handleSaveCurrentPage = async () => {
setSaving(true)
try {
const tabs = await chrome.tabs.query({
active: true,
currentWindow: true,
})
if (tabs.length > 0 && tabs[0].id) {
await chrome.tabs.sendMessage(tabs[0].id, {
action: "saveMemory",
})
}
} catch (error) {
console.error("Failed to save current page:", error)
} finally {
setSaving(false)
}
}
const handleSignOut = async () => {
try {
await chrome.storage.local.remove([STORAGE_KEYS.BEARER_TOKEN])
setUserSignedIn(false)
queryClient.clear()
} catch (error) {
console.error("Error signing out:", error)
}
}
if (loading) {
return (
<div className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden">
<div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
<img alt="supermemory" className="w-8 h-8 flex-shrink-0" src="/icon-48.png" />
<h1 className="m-0 text-lg font-semibold text-black flex-1">supermemory</h1>
</div>
<div className="p-4">
<div>Loading...</div>
</div>
</div>
)
}
return (
<div className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden">
<div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
<img
alt="supermemory"
className="w-8 h-8 flex-shrink-0"
src="https://assets.supermemory.ai/brand/wordmark/dark-transparent.svg"
style={{ width: "80%", height: "45px" }}
/>
{userSignedIn && (
<button
className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200 hover:text-black hover:bg-gray-100"
onClick={handleSignOut}
title="Logout"
type="button"
>
<svg
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<title>Logout</title>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16,17 21,12 16,7" />
<line x1="21" x2="9" y1="12" y2="12" />
</svg>
</button>
)}
</div>
<div className="p-4">
{userSignedIn ? (
<div className="text-left">
{/* Tab Navigation */}
<div className="flex bg-gray-100 rounded-lg p-1 mb-4">
<button
className={`flex-1 py-2 px-4 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
activeTab === "save"
? "bg-white text-black shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => setActiveTab("save")}
type="button"
>
Save
</button>
<button
className={`flex-1 py-2 px-4 bg-transparent border-none rounded-md text-sm font-medium cursor-pointer transition-all duration-200 outline-none appearance-none ${
activeTab === "imports"
? "bg-white text-black shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => setActiveTab("imports")}
type="button"
>
Imports
</button>
</div>
{/* Tab Content */}
{activeTab === "save" ? (
<div className="flex flex-col gap-4 min-h-[200px]">
{/* Current Page Info */}
<div className="mb-0">
<div className="bg-gray-50 p-3 rounded-md border border-gray-200">
<h3 className="m-0 mb-1 text-sm font-semibold text-black overflow-hidden text-ellipsis whitespace-nowrap">
{currentTitle || "Current Page"}
</h3>
<p className="m-0 text-xs text-gray-500 overflow-hidden text-ellipsis whitespace-nowrap">{currentUrl}</p>
</div>
</div>
{/* Project Selection */}
<div className="mb-0">
<button
className="w-full bg-transparent border-none p-0 cursor-pointer text-left"
onClick={handleShowProjectSelector}
type="button"
>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-200 transition-colors duration-200 hover:bg-gray-200 hover:border-gray-300">
<span className="text-sm font-medium text-gray-600">Save to project:</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-black overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
{defaultProject
? defaultProject.name
: "Default Project"}
</span>
<svg
aria-label="Select project"
className="text-gray-500 flex-shrink-0 transition-transform duration-200 hover:text-gray-700 hover:translate-x-0.5"
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<title>Select project</title>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</div>
</button>
</div>
{/* Save Button at Bottom */}
<div className="mt-auto pt-4">
<button
className="w-full py-3 px-6 bg-gray-700 text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={saving}
onClick={handleSaveCurrentPage}
type="button"
>
{saving ? "Saving..." : "Save Current Page"}
</button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 min-h-[200px]">
{/* Import Actions */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<button
className="w-full py-3 px-3 bg-white text-black border border-gray-200 rounded-md text-sm font-medium cursor-pointer flex items-center justify-center transition-colors duration-200 hover:bg-gray-50"
onClick={() => {
chrome.tabs.create({
url: "https://chatgpt.com/#settings/Personalization",
})
}}
type="button"
>
<svg
aria-label="ChatGPT Logo"
className="w-4.5 h-4.5 flex-shrink-0 mr-2"
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
Import ChatGPT Memories
</button>
</div>
<div className="flex flex-col gap-2">
<button
className="w-full py-3 px-3 bg-white text-black border border-gray-200 rounded-md text-sm font-medium cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-gray-50 focus:outline-none"
onClick={() => {
chrome.tabs.create({
url: "https://x.com/i/bookmarks",
})
}}
type="button"
>
<svg
aria-label="X Twitter Logo"
className="w-4.5 h-4.5 flex-shrink-0 mr-2"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>X Twitter Logo</title>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Import X Bookmarks
</button>
<p className="m-0 text-xs text-gray-500 leading-tight pl-1">
Click on supermemory on top right to import bookmarks
</p>
</div>
</div>
</div>
)}
{showProjectSelector && (
<div className="absolute inset-0 bg-white rounded-lg z-[1000] shadow-xl flex flex-col">
<div className="flex justify-between items-center p-4 border-b border-gray-200 text-base font-semibold text-black flex-shrink-0">
<span>Select the Project</span>
<button
className="bg-transparent border-none text-xl cursor-pointer text-gray-500 p-0 w-6 h-6 flex items-center justify-center hover:text-black"
onClick={() => setShowProjectSelector(false)}
type="button"
>
×
</button>
</div>
{loadingProjects ? (
<div className="py-8 px-4 text-center text-gray-500 text-sm">Loading projects...</div>
) : (
<div className="flex-1 overflow-y-auto min-h-0">
{projects.map((project) => (
<button
className={`flex justify-between items-center py-3 px-4 cursor-pointer transition-colors duration-200 border-b border-gray-100 bg-transparent border-none w-full text-left last:border-b-0 hover:bg-gray-50 ${
defaultProject?.id === project.id ? "bg-blue-50" : ""
}`}
key={project.id}
onClick={() => handleProjectSelect(project)}
type="button"
>
<div className="flex flex-col flex-1 gap-0.5">
<span className="text-sm font-medium text-black break-words leading-tight">
{project.name}
</span>
<span className="text-xs text-gray-500">
{project.documentCount} docs
</span>
</div>
{defaultProject?.id === project.id && (
<span className="text-blue-600 font-bold text-base"></span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
) : (
<div className="text-center py-2">
<div className="mb-8">
<h2 className="m-0 mb-4 text-sm font-normal text-black leading-tight">
Login to unlock all chrome extension features
</h2>
<ul className="list-none p-0 m-0 text-left">
<li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">Save any page to your supermemory</li>
<li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">Import all your Twitter / X Bookmarks</li>
<li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">Import your ChatGPT Memories</li>
</ul>
</div>
<div className="mt-8">
<p className="m-0 mb-4 text-sm text-gray-500">
Having trouble logging in?{" "}
<button
className="bg-transparent border-none text-blue-500 cursor-pointer underline text-sm p-0 hover:text-blue-700"
onClick={() => {
window.open("mailto:dhravya@supermemory.com", "_blank")
}}
type="button"
>
Reach Out to Us
</button>
</p>
<button
className="w-full py-3 px-6 bg-gray-700 text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={() => {
chrome.tabs.create({
url: import.meta.env.PROD
? "https://app.supermemory.ai/login"
: "http://localhost:3000/login",
})
}}
type="button"
>
login in
</button>
</div>
</div>
)}
</div>
</div>
)
}
export default App

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
import { QueryClientProvider } from "@tanstack/react-query"
import React from "react"
import ReactDOM from "react-dom/client"
import { queryClient } from "../../utils/query-client"
import App from "./App.js"
import "./style.css"
const rootElement = document.getElementById("root")
if (rootElement) {
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
}

View file

@ -0,0 +1,27 @@
:root {
font-family:
"Space Grotesk", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
}

View file

@ -0,0 +1,105 @@
function Welcome() {
return (
<div className="min-h-screen font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] flex items-center justify-center p-8 bg-gradient-to-br from-gray-50 to-white">
<div className="max-w-4xl w-full text-center">
{/* Header */}
<div className="mb-12">
<img
alt="supermemory"
className="h-16 mb-6 mx-auto"
src="https://assets.supermemory.ai/brand/wordmark/dark-transparent.svg"
/>
<p className="text-gray-600 text-lg font-normal max-w-2xl mx-auto">
Your AI-powered second brain for saving and organizing everything
that matters
</p>
</div>
{/* Features Section */}
<div className="mb-12">
<h2 className="text-2xl font-semibold text-black mb-8"
>What can you do with supermemory
?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
<div className="text-3xl mb-4 block">💾</div>
<h3 className="text-lg font-semibold text-black mb-3"
>Save Any Pag
e</h3>
<p className="text-sm text-gray-600 leading-snug">
Instantly save web pages, articles, and content to your personal
knowledge base
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
<div className="text-3xl mb-4 block">🐦</div>
<h3 className="text-lg font-semibold text-black mb-3">
Import Twitter/X Bookmarks
</h3>
<p className="text-sm text-gray-600 leading-snug">
Bring all your saved tweets and bookmarks into one organized
place
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
<div className="text-3xl mb-4 block">🤖</div>
<h3 className="text-lg font-semibold text-black mb-3">
Import ChatGPT Memories
</h3>
<p className="text-sm text-gray-600 leading-snug">
Keep your important AI conversations and insights accessible
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
<div className="text-3xl mb-4 block">🔍</div>
<h3 className="text-lg font-semibold text-black mb-3">
AI-Powered Search
</h3>
<p className="text-sm text-gray-600 leading-snug">
Find anything you've saved using intelligent semantic search
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="mb-8">
<button
className="min-w-[200px] px-8 py-4 bg-gray-700 text-white border-none rounded-3xl text-base font-semibold cursor-pointer transition-colors duration-200 mb-4 outline-none hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={() => {
chrome.tabs.create({
url: import.meta.env.PROD
? "https://app.supermemory.ai/login"
: "http://localhost:3000/login",
})
}}
type="button"
>
Login to Get started
</button>
</div>
{/* Footer */}
<div className="border-t border-gray-200 pt-6 mt-8">
<p className="text-sm text-gray-600">
Learn more at{" "}
<a
className="text-blue-500 no-underline hover:underline hover:text-blue-700"
href="https://supermemory.ai"
rel="noopener noreferrer"
target="_blank"
>
supermemory.ai
</a>
</p>
</div>
</div>
</div>
)
}
export default Welcome

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon-16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to supermemory</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
import { QueryClientProvider } from "@tanstack/react-query"
import React from "react"
import ReactDOM from "react-dom/client"
import { queryClient } from "../../utils/query-client"
import Welcome from "./Welcome"
import "./welcome.css"
const rootElement = document.getElementById("root")
if (rootElement) {
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<Welcome />
</QueryClientProvider>
</React.StrictMode>,
)
}

View file

@ -0,0 +1,49 @@
@import "tailwindcss";
/* Custom Font Definitions */
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype");
}
/* Global Styles */
body {
font-family:
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,32 @@
{
"name": "supermemory-browser-extension",
"description": "Browser extension for the supermemory app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "wxt --port 3001",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.12"
},
"devDependencies": {
"@types/chrome": "^0.1.4",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@wxt-dev/module-react": "^1.1.3",
"typescript": "^5.8.3",
"wxt": "^0.20.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View file

@ -0,0 +1,8 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"types": ["chrome"]
}
}

View file

@ -0,0 +1,156 @@
/**
* API service for supermemory browser extension
*/
import { API_ENDPOINTS, STORAGE_KEYS } from "./constants"
import {
AuthenticationError,
type MemoryPayload,
type Project,
type ProjectsResponse,
SupermemoryAPIError,
} from "./types"
/**
* Get bearer token from storage
*/
async function getBearerToken(): Promise<string> {
const result = await chrome.storage.local.get([STORAGE_KEYS.BEARER_TOKEN])
const token = result[STORAGE_KEYS.BEARER_TOKEN]
if (!token) {
throw new AuthenticationError("Bearer token not found")
}
return token
}
/**
* Make authenticated API request
*/
async function makeAuthenticatedRequest<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const token = await getBearerToken()
const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}${endpoint}`, {
...options,
credentials: "omit",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
if (response.status === 401) {
throw new AuthenticationError("Invalid or expired token")
}
throw new SupermemoryAPIError(
`API request failed: ${response.statusText}`,
response.status,
)
}
return response.json()
}
/**
* Fetch all projects from API
*/
export async function fetchProjects(): Promise<Project[]> {
try {
const response =
await makeAuthenticatedRequest<ProjectsResponse>("/v3/projects")
return response.projects
} catch (error) {
console.error("Failed to fetch projects:", error)
throw error
}
}
/**
* Get default project from storage
*/
export async function getDefaultProject(): Promise<Project | null> {
try {
const result = await chrome.storage.local.get([
STORAGE_KEYS.DEFAULT_PROJECT,
])
return result[STORAGE_KEYS.DEFAULT_PROJECT] || null
} catch (error) {
console.error("Failed to get default project:", error)
return null
}
}
/**
* Set default project in storage
*/
export async function setDefaultProject(project: Project): Promise<void> {
try {
await chrome.storage.local.set({
[STORAGE_KEYS.DEFAULT_PROJECT]: project,
})
} catch (error) {
console.error("Failed to set default project:", error)
throw error
}
}
/**
* Save memory to Supermemory API
*/
export async function saveMemory(payload: MemoryPayload): Promise<unknown> {
try {
const response = await makeAuthenticatedRequest<unknown>("/v3/memories", {
method: "POST",
body: JSON.stringify(payload),
})
return response
} catch (error) {
console.error("Failed to save memory:", error)
throw error
}
}
/**
* Search memories using Supermemory API
*/
export async function searchMemories(query: string): Promise<unknown> {
try {
const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
method: "POST",
body: JSON.stringify({ q: query }),
})
return response
} catch (error) {
console.error("Failed to search memories:", error)
throw error
}
}
/**
* Save tweet to Supermemory API (specific for Twitter imports)
*/
export async function saveTweet(
content: string,
metadata: { sm_source: string; [key: string]: unknown },
containerTag = "sm_project_twitter_bookmarks",
): Promise<void> {
try {
const payload: MemoryPayload = {
containerTags: [containerTag],
content,
metadata,
}
await saveMemory(payload)
} catch (error) {
if (error instanceof SupermemoryAPIError && error.statusCode === 409) {
// Skip if already exists (409 Conflict)
return
}
throw error
}
}

View file

@ -0,0 +1,81 @@
/**
* API Endpoints
*/
export const API_ENDPOINTS = {
SUPERMEMORY_API: import.meta.env.PROD
? "https://api.supermemory.ai"
: "http://localhost:8787",
SUPERMEMORY_WEB: import.meta.env.PROD
? "https://app.supermemory.ai"
: "http://localhost:3000",
} as const
/**
* Storage Keys
*/
export const STORAGE_KEYS = {
BEARER_TOKEN: "bearer-token",
TOKENS_LOGGED: "tokens-logged",
TWITTER_COOKIE: "twitter-cookie",
TWITTER_CSRF: "twitter-csrf",
TWITTER_AUTH_TOKEN: "twitter-auth-token",
DEFAULT_PROJECT: "sm-default-project",
} as const
/**
* DOM Element IDs
*/
export const ELEMENT_IDS = {
TWITTER_IMPORT_BUTTON: "sm-twitter-import-button",
TWITTER_IMPORT_STATUS: "sm-twitter-import-status",
TWITTER_CLOSE_BTN: "sm-twitter-close-btn",
TWITTER_IMPORT_BTN: "sm-twitter-import-btn",
TWITTER_SIGNIN_BTN: "sm-twitter-signin-btn",
SUPERMEMORY_TOAST: "sm-toast",
SUPERMEMORY_SAVE_BUTTON: "sm-save-button",
SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element",
} as const
/**
* UI Configuration
*/
export const UI_CONFIG = {
BUTTON_SHOW_DELAY: 2000, // milliseconds
TOAST_DURATION: 3000, // milliseconds
RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
PAGINATION_DELAY: 1000, // 1 second between requests
} as const
/**
* Supported Domains
*/
export const DOMAINS = {
TWITTER: ["x.com", "twitter.com"],
CHATGPT: ["chatgpt.com", "chat.openai.com"],
SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"],
} as const
/**
* Container Tags
*/
export const CONTAINER_TAGS = {
TWITTER_BOOKMARKS: "sm_project_twitter_bookmarks",
DEFAULT_PROJECT: "sm_project_default",
} as const
/**
* Message Types for extension communication
*/
export const MESSAGE_TYPES = {
SAVE_MEMORY: "sm-save-memory",
SHOW_TOAST: "sm-show-toast",
BATCH_IMPORT_ALL: "sm-batch-import-all",
IMPORT_UPDATE: "sm-import-update",
IMPORT_DONE: "sm-import-done",
GET_RELATED_MEMORIES: "sm-get-related-memories",
} as const
export const CONTEXT_MENU_IDS = {
SAVE_TO_SUPERMEMORY: "sm-save-to-supermemory",
} as const

View file

@ -0,0 +1,24 @@
/**
* React Query configuration for supermemory browser extension
*/
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (previously cacheTime)
retry: (failureCount, error) => {
// Don't retry on authentication errors
if (error?.constructor?.name === "AuthenticationError") {
return false
}
return failureCount < 3
},
refetchOnWindowFocus: false,
},
mutations: {
retry: 1,
},
},
})

View file

@ -0,0 +1,64 @@
/**
* React Query hooks for supermemory API
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
fetchProjects,
getDefaultProject,
saveMemory,
searchMemories,
setDefaultProject,
} from "./api"
import type { MemoryPayload } from "./types"
// Query Keys
export const queryKeys = {
projects: ["projects"] as const,
defaultProject: ["defaultProject"] as const,
}
// Projects Query
export function useProjects(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.projects,
queryFn: fetchProjects,
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: options?.enabled ?? true,
})
}
// Default Project Query
export function useDefaultProject(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.defaultProject,
queryFn: getDefaultProject,
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: options?.enabled ?? true,
})
}
// Set Default Project Mutation
export function useSetDefaultProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setDefaultProject,
onSuccess: (_, project) => {
queryClient.setQueryData(queryKeys.defaultProject, project)
},
})
}
// Save Memory Mutation
export function useSaveMemory() {
return useMutation({
mutationFn: (payload: MemoryPayload) => saveMemory(payload),
})
}
// Search Memories Mutation
export function useSearchMemories() {
return useMutation({
mutationFn: (query: string) => searchMemories(query),
})
}

View file

@ -0,0 +1,101 @@
/**
* Twitter Authentication Module
* Handles token capture and storage for Twitter API access
*/
import { STORAGE_KEYS } from "./constants"
export interface TwitterAuthTokens {
cookie: string
csrf: string
auth: string
}
/**
* Captures Twitter authentication tokens from web request headers
* @param details - Web request details containing headers
* @returns True if tokens were captured, false otherwise
*/
export function captureTwitterTokens(
details: chrome.webRequest.WebRequestDetails & {
requestHeaders?: chrome.webRequest.HttpHeader[]
},
): boolean {
if (!(details.url.includes("x.com") || details.url.includes("twitter.com"))) {
return false
}
const authHeader = details.requestHeaders?.find(
(header) => header.name.toLowerCase() === "authorization",
)
const cookieHeader = details.requestHeaders?.find(
(header) => header.name.toLowerCase() === "cookie",
)
const csrfHeader = details.requestHeaders?.find(
(header) => header.name.toLowerCase() === "x-csrf-token",
)
if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) {
chrome.storage.session.get([STORAGE_KEYS.TOKENS_LOGGED], (result) => {
if (!result[STORAGE_KEYS.TOKENS_LOGGED]) {
console.log("Twitter auth tokens captured successfully")
chrome.storage.session.set({ [STORAGE_KEYS.TOKENS_LOGGED]: true })
}
})
chrome.storage.session.set({
[STORAGE_KEYS.TWITTER_COOKIE]: cookieHeader.value,
[STORAGE_KEYS.TWITTER_CSRF]: csrfHeader.value,
[STORAGE_KEYS.TWITTER_AUTH_TOKEN]: authHeader.value,
})
return true
}
return false
}
/**
* Retrieves stored Twitter authentication tokens
* @returns Promise resolving to tokens or null if not available
*/
export async function getTwitterTokens(): Promise<TwitterAuthTokens | null> {
const result = await chrome.storage.session.get([
STORAGE_KEYS.TWITTER_COOKIE,
STORAGE_KEYS.TWITTER_CSRF,
STORAGE_KEYS.TWITTER_AUTH_TOKEN,
])
if (
!result[STORAGE_KEYS.TWITTER_COOKIE] ||
!result[STORAGE_KEYS.TWITTER_CSRF] ||
!result[STORAGE_KEYS.TWITTER_AUTH_TOKEN]
) {
return null
}
return {
cookie: result[STORAGE_KEYS.TWITTER_COOKIE],
csrf: result[STORAGE_KEYS.TWITTER_CSRF],
auth: result[STORAGE_KEYS.TWITTER_AUTH_TOKEN],
}
}
/**
* Creates HTTP headers for Twitter API requests using stored tokens
* @param tokens - Twitter authentication tokens
* @returns Headers object ready for fetch requests
*/
export function createTwitterAPIHeaders(tokens: TwitterAuthTokens): Headers {
const headers = new Headers()
headers.append("Cookie", tokens.cookie)
headers.append("X-Csrf-Token", tokens.csrf)
headers.append("Authorization", tokens.auth)
headers.append("Content-Type", "application/json")
headers.append(
"User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
)
headers.append("Accept", "*/*")
headers.append("Accept-Language", "en-US,en;q=0.9")
return headers
}

View file

@ -0,0 +1,192 @@
/**
* Twitter Bookmarks Import Module
* Handles the import process for Twitter bookmarks
*/
import { saveTweet } from "./api"
import { createTwitterAPIHeaders, getTwitterTokens } from "./twitter-auth"
import {
BOOKMARKS_URL,
buildRequestVariables,
extractNextCursor,
getAllTweets,
type Tweet,
type TwitterAPIResponse,
tweetToMarkdown,
} from "./twitter-utils"
export type ImportProgressCallback = (message: string) => Promise<void>
export type ImportCompleteCallback = (totalImported: number) => Promise<void>
export interface TwitterImportConfig {
onProgress: ImportProgressCallback
onComplete: ImportCompleteCallback
onError: (error: Error) => Promise<void>
}
/**
* Rate limiting configuration
*/
class RateLimiter {
private waitTime = 60000 // Start with 1 minute
async handleRateLimit(onProgress: ImportProgressCallback): Promise<void> {
const waitTimeInSeconds = this.waitTime / 1000
await onProgress(
`Rate limit reached. Waiting for ${waitTimeInSeconds} seconds before retrying...`,
)
await new Promise((resolve) => setTimeout(resolve, this.waitTime))
this.waitTime *= 2 // Exponential backoff
}
reset(): void {
this.waitTime = 60000
}
}
/**
* Imports a single tweet to Supermemory
* @param tweetMd - Tweet content in markdown format
* @param tweet - Original tweet object with metadata
* @returns Promise that resolves when tweet is imported
*/
async function importTweet(tweetMd: string, tweet: Tweet): Promise<void> {
const metadata = {
sm_source: "consumer",
tweet_id: tweet.id_str,
author: tweet.user.screen_name,
created_at: tweet.created_at,
likes: tweet.favorite_count,
retweets: tweet.retweet_count || 0,
}
try {
await saveTweet(tweetMd, metadata)
} catch (error) {
throw new Error(
`Failed to save tweet: ${error instanceof Error ? error.message : "Unknown error"}`,
)
}
}
/**
* Main class for handling Twitter bookmarks import
*/
export class TwitterImporter {
private importInProgress = false
private rateLimiter = new RateLimiter()
constructor(private config: TwitterImportConfig) {}
/**
* Starts the import process for all Twitter bookmarks
* @returns Promise that resolves when import is complete
*/
async startImport(): Promise<void> {
if (this.importInProgress) {
throw new Error("Import already in progress")
}
this.importInProgress = true
try {
await this.batchImportAll("", 0)
this.rateLimiter.reset()
} catch (error) {
await this.config.onError(error as Error)
} finally {
this.importInProgress = false
}
}
/**
* Recursive function to import all bookmarks with pagination
* @param cursor - Pagination cursor for Twitter API
* @param totalImported - Number of tweets imported so far
*/
private async batchImportAll(cursor = "", totalImported = 0): Promise<void> {
try {
// Use a local variable to track imported count
let importedCount = totalImported
// Get authentication tokens
const tokens = await getTwitterTokens()
if (!tokens) {
await this.config.onProgress(
"Please visit Twitter/X first to capture authentication tokens",
)
return
}
// Create headers for API request
const headers = createTwitterAPIHeaders(tokens)
// Build API request with pagination
const variables = buildRequestVariables(cursor)
const urlWithCursor = cursor
? `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}`
: BOOKMARKS_URL
console.log("Making Twitter API request to:", urlWithCursor)
console.log("Request headers:", Object.fromEntries(headers.entries()))
const response = await fetch(urlWithCursor, {
method: "GET",
headers,
redirect: "follow",
})
if (!response.ok) {
const errorText = await response.text()
console.error(`Twitter API Error ${response.status}:`, errorText)
if (response.status === 429) {
await this.rateLimiter.handleRateLimit(this.config.onProgress)
return this.batchImportAll(cursor, totalImported)
}
throw new Error(
`Failed to fetch data: ${response.status} - ${errorText}`,
)
}
const data: TwitterAPIResponse = await response.json()
const tweets = getAllTweets(data)
console.log("Tweets:", tweets)
// Process each tweet
for (const tweet of tweets) {
try {
const tweetMd = tweetToMarkdown(tweet)
await importTweet(tweetMd, tweet)
importedCount++
await this.config.onProgress(`Imported ${importedCount} tweets`)
} catch (error) {
console.error("Error importing tweet:", error)
// Continue with next tweet
}
}
// Handle pagination
const instructions =
data.data?.bookmark_timeline_v2?.timeline?.instructions
const nextCursor = extractNextCursor(instructions || [])
console.log("Next cursor:", nextCursor)
console.log("Tweets length:", tweets.length)
if (nextCursor && tweets.length > 0) {
await new Promise((resolve) => setTimeout(resolve, 1000)) // Rate limiting
await this.batchImportAll(nextCursor, importedCount)
} else {
await this.config.onComplete(importedCount)
}
} catch (error) {
console.error("Batch import error:", error)
await this.config.onError(error as Error)
}
}
}

View file

@ -0,0 +1,377 @@
// Twitter API data structures and transformation utilities
interface TwitterAPITweet {
__typename?: string
legacy: {
lang?: string
favorite_count: number
created_at: string
display_text_range?: [number, number]
entities?: {
hashtags?: Array<{ indices: [number, number]; text: string }>
urls?: Array<{
display_url: string
expanded_url: string
indices: [number, number]
url: string
}>
user_mentions?: Array<{
id_str: string
indices: [number, number]
name: string
screen_name: string
}>
symbols?: Array<{ indices: [number, number]; text: string }>
media?: MediaEntity[]
}
id_str: string
full_text: string
reply_count?: number
retweet_count?: number
quote_count?: number
}
core?: {
user_results?: {
result?: {
legacy?: {
id_str: string
name: string
profile_image_url_https: string
screen_name: string
verified: boolean
}
is_blue_verified?: boolean
}
}
}
}
interface MediaEntity {
type: string
media_url_https: string
sizes?: {
large?: {
w: number
h: number
}
}
video_info?: {
variants?: Array<{
url: string
}>
duration_millis?: number
}
}
export interface Tweet {
__typename?: string
lang?: string
favorite_count: number
created_at: string
display_text_range?: [number, number]
entities: {
hashtags: Array<{
indices: [number, number]
text: string
}>
urls?: Array<{
display_url: string
expanded_url: string
indices: [number, number]
url: string
}>
user_mentions: Array<{
id_str: string
indices: [number, number]
name: string
screen_name: string
}>
symbols: Array<{
indices: [number, number]
text: string
}>
}
id_str: string
text: string
user: {
id_str: string
name: string
profile_image_url_https: string
screen_name: string
verified: boolean
is_blue_verified?: boolean
}
conversation_count: number
photos?: Array<{
url: string
width: number
height: number
}>
videos?: Array<{
url: string
thumbnail_url: string
duration: number
}>
retweet_count?: number
quote_count?: number
reply_count?: number
}
export interface TwitterAPIResponse {
data: {
bookmark_timeline_v2: {
timeline: {
instructions: Array<{
type: string
entries?: Array<{
entryId: string
sortIndex: string
content: Record<string, unknown>
}>
}>
}
}
}
}
// Twitter API features configuration
export const TWITTER_API_FEATURES = {
graphql_timeline_v2_bookmark_timeline: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false,
rweb_tipjar_consumption_enabled: true,
responsive_web_twitter_article_notes_tab_enabled: true,
creator_subscriptions_tweet_preview_api_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_media_download_video_enabled: false,
responsive_web_text_conversations_enabled: false,
// Missing features that the API is complaining about
creator_subscriptions_quote_tweet_preview_enabled: true,
view_counts_everywhere_api_enabled: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
tweetypie_unmention_optimization_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: true,
communities_web_enable_tweet_community_results_fetch: true,
responsive_web_edit_tweet_api_enabled: true,
longform_notetweets_consumption_enabled: true,
articles_preview_enabled: true,
rweb_video_timestamps_enabled: true,
verified_phone_label_enabled: true,
}
export const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(TWITTER_API_FEATURES))}`
/**
* Transform raw Twitter API response data into standardized Tweet format
*/
export function transformTweetData(
input: Record<string, unknown>,
): Tweet | null {
try {
const content = input.content as {
itemContent?: { tweet_results?: { result?: unknown } }
}
const tweetData = content?.itemContent?.tweet_results?.result
if (!tweetData) {
return null
}
const tweet = tweetData as TwitterAPITweet
if (!tweet.legacy) {
return null
}
// Handle media entities
const media = (tweet.legacy.entities?.media as MediaEntity[]) || []
const photos = media
.filter((m) => m.type === "photo")
.map((m) => ({
url: m.media_url_https,
width: m.sizes?.large?.w || 0,
height: m.sizes?.large?.h || 0,
}))
const videos = media
.filter((m) => m.type === "video")
.map((m) => ({
url: m.video_info?.variants?.[0]?.url || "",
thumbnail_url: m.media_url_https,
duration: m.video_info?.duration_millis || 0,
}))
const transformed: Tweet = {
__typename: tweet.__typename,
lang: tweet.legacy?.lang,
favorite_count: tweet.legacy.favorite_count || 0,
created_at: new Date(tweet.legacy.created_at).toISOString(),
display_text_range: tweet.legacy.display_text_range,
entities: {
hashtags: tweet.legacy.entities?.hashtags || [],
urls: tweet.legacy.entities?.urls || [],
user_mentions: tweet.legacy.entities?.user_mentions || [],
symbols: tweet.legacy.entities?.symbols || [],
},
id_str: tweet.legacy.id_str,
text: tweet.legacy.full_text,
user: {
id_str: tweet.core?.user_results?.result?.legacy?.id_str || "",
name: tweet.core?.user_results?.result?.legacy?.name || "Unknown",
profile_image_url_https:
tweet.core?.user_results?.result?.legacy?.profile_image_url_https ||
"",
screen_name:
tweet.core?.user_results?.result?.legacy?.screen_name || "unknown",
verified: tweet.core?.user_results?.result?.legacy?.verified || false,
is_blue_verified:
tweet.core?.user_results?.result?.is_blue_verified || false,
},
conversation_count: tweet.legacy.reply_count || 0,
retweet_count: tweet.legacy.retweet_count || 0,
quote_count: tweet.legacy.quote_count || 0,
reply_count: tweet.legacy.reply_count || 0,
}
if (photos.length > 0) {
transformed.photos = photos
}
if (videos.length > 0) {
transformed.videos = videos
}
return transformed
} catch (error) {
console.error("Error transforming tweet data:", error)
return null
}
}
/**
* Extract all tweets from Twitter API response
*/
export function getAllTweets(data: TwitterAPIResponse): Tweet[] {
const tweets: Tweet[] = []
try {
const instructions =
data.data?.bookmark_timeline_v2?.timeline?.instructions || []
for (const instruction of instructions) {
if (instruction.type === "TimelineAddEntries" && instruction.entries) {
for (const entry of instruction.entries) {
if (entry.entryId.startsWith("tweet-")) {
const tweet = transformTweetData(entry)
if (tweet) {
tweets.push(tweet)
}
}
}
}
}
} catch (error) {
console.error("Error extracting tweets:", error)
}
return tweets
}
/**
* Extract pagination cursor from Twitter API response
*/
export function extractNextCursor(
instructions: Array<Record<string, unknown>>,
): string | null {
try {
for (const instruction of instructions) {
if (instruction.type === "TimelineAddEntries" && instruction.entries) {
const entries = instruction.entries as Array<{
entryId: string
content?: { value?: string }
}>
for (const entry of entries) {
if (entry.entryId.startsWith("cursor-bottom-")) {
return entry.content?.value || null
}
}
}
}
} catch (error) {
console.error("Error extracting cursor:", error)
}
return null
}
/**
* Convert Tweet object to markdown format for storage
*/
export function tweetToMarkdown(tweet: Tweet): string {
const username = tweet.user?.screen_name || "unknown"
const displayName = tweet.user?.name || "Unknown User"
const date = new Date(tweet.created_at).toLocaleDateString()
const time = new Date(tweet.created_at).toLocaleTimeString()
let markdown = `# Tweet by @${username} (${displayName})\n\n`
markdown += `**Date:** ${date} ${time}\n`
markdown += `**Likes:** ${tweet.favorite_count} | **Retweets:** ${tweet.retweet_count || 0} | **Replies:** ${tweet.reply_count || 0}\n\n`
// Add tweet text
markdown += `${tweet.text}\n\n`
// Add media if present
if (tweet.photos && tweet.photos.length > 0) {
markdown += "**Images:**\n"
tweet.photos.forEach((photo, index) => {
markdown += `![Image ${index + 1}](${photo.url})\n`
})
markdown += "\n"
}
if (tweet.videos && tweet.videos.length > 0) {
markdown += "**Videos:**\n"
tweet.videos.forEach((video, index) => {
markdown += `[Video ${index + 1}](${video.url})\n`
})
markdown += "\n"
}
// Add hashtags and mentions
if (tweet.entities.hashtags.length > 0) {
markdown += `**Hashtags:** ${tweet.entities.hashtags.map((h) => `#${h.text}`).join(", ")}\n`
}
if (tweet.entities.user_mentions.length > 0) {
markdown += `**Mentions:** ${tweet.entities.user_mentions.map((m) => `@${m.screen_name}`).join(", ")}\n`
}
// Add raw data for reference
markdown += `\n---\n<details>\n<summary>Raw Tweet Data</summary>\n\n\`\`\`json\n${JSON.stringify(tweet, null, 2)}\n\`\`\`\n</details>`
return markdown
}
/**
* Build Twitter API request variables for pagination
*/
export function buildRequestVariables(cursor?: string, count = 100) {
const variables = {
count,
includePromotedContent: false,
}
if (cursor) {
;(variables as Record<string, unknown>).cursor = cursor
}
return variables
}

View file

@ -0,0 +1,149 @@
/**
* Type definitions for the browser extension
*/
/**
* Toast states for UI feedback
*/
export type ToastState = "loading" | "success" | "error"
/**
* Message types for extension communication
*/
export interface ExtensionMessage {
action?: string
type?: string
data?: unknown
state?: ToastState
importedMessage?: string
totalImported?: number
}
/**
* Memory data structure for saving content
*/
export interface MemoryData {
html: string
highlightedText?: string
url?: string
}
/**
* Supermemory API payload for storing memories
*/
export interface MemoryPayload {
containerTags: string[]
content: string
metadata: {
sm_source: string
[key: string]: unknown
}
}
/**
* Twitter-specific memory metadata
*/
export interface TwitterMemoryMetadata {
sm_source: "twitter_bookmarks"
tweet_id: string
author: string
created_at: string
likes: number
retweets: number
}
/**
* Storage data structure for Chrome storage
*/
export interface StorageData {
bearerToken?: string
twitterAuth?: {
cookie: string
csrf: string
auth: string
}
tokens_logged?: boolean
cookie?: string
csrf?: string
auth?: string
defaultProject?: Project
projectsCache?: {
projects: Project[]
timestamp: number
}
}
/**
* Context menu click info
*/
export interface ContextMenuClickInfo {
menuItemId: string | number
editable?: boolean
frameId?: number
frameUrl?: string
linkUrl?: string
mediaType?: string
pageUrl?: string
parentMenuItemId?: string | number
selectionText?: string
srcUrl?: string
targetElementId?: number
wasChecked?: boolean
}
/**
* API Response types
*/
export interface APIResponse<T = unknown> {
success: boolean
data?: T
error?: string
}
/**
* Error types for better error handling
*/
export class ExtensionError extends Error {
constructor(
message: string,
public code?: string,
public statusCode?: number,
) {
super(message)
this.name = "ExtensionError"
}
}
export class TwitterAPIError extends ExtensionError {
constructor(message: string, statusCode?: number) {
super(message, "TWITTER_API_ERROR", statusCode)
this.name = "TwitterAPIError"
}
}
export class SupermemoryAPIError extends ExtensionError {
constructor(message: string, statusCode?: number) {
super(message, "SUPERMEMORY_API_ERROR", statusCode)
this.name = "SupermemoryAPIError"
}
}
export class AuthenticationError extends ExtensionError {
constructor(message = "Authentication required") {
super(message, "AUTH_ERROR")
this.name = "AuthenticationError"
}
}
export interface Project {
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
documentCount: number
}
export interface ProjectsResponse {
projects: Project[]
}

View file

@ -0,0 +1,450 @@
/**
* UI Components Module
* Reusable UI components for the browser extension
*/
import { API_ENDPOINTS, ELEMENT_IDS, UI_CONFIG } from "./constants"
import type { ToastState } from "./types"
/**
* Creates a toast notification element
* @param state - The state of the toast (loading, success, error)
* @returns HTMLElement - The toast element
*/
export function createToast(state: ToastState): HTMLElement {
const toast = document.createElement("div")
toast.id = ELEMENT_IDS.SUPERMEMORY_TOAST
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 2147483647;
background: #ffffff;
border-radius: 9999px;
padding: 12px 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: 200px;
max-width: 300px;
animation: slideIn 0.3s ease-out;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
`
// Add keyframe animations and fonts if not already present
if (!document.getElementById("supermemory-toast-styles")) {
const style = document.createElement("style")
style.id = "supermemory-toast-styles"
style.textContent = `
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Light.ttf")}') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Regular.ttf")}') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Medium.ttf")}') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-SemiBold.ttf")}') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Bold.ttf")}') format('truetype');
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
document.head.appendChild(style)
}
const icon = document.createElement("div")
icon.style.cssText = "width: 20px; height: 20px; flex-shrink: 0;"
const text = document.createElement("span")
text.style.fontWeight = "500"
// Configure toast based on state
switch (state) {
case "loading":
icon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6V2" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<path d="M12 22V18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<path d="M20.49 8.51L18.36 6.38" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
<path d="M5.64 17.64L3.51 15.51" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
<path d="M22 12H18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.8"/>
<path d="M6 12H2" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<path d="M20.49 15.49L18.36 17.62" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
<path d="M5.64 6.36L3.51 8.49" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
</svg>
`
icon.style.animation = "spin 1s linear infinite"
text.textContent = "Adding to Memory..."
break
case "success": {
const iconUrl = browser.runtime.getURL("/icon-16.png")
icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`
text.textContent = "Added to Memory"
break
}
case "error":
icon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
<path d="M15 9L9 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9L15 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
text.textContent = "Failed to save memory / Make sure you are logged in"
break
}
toast.appendChild(icon)
toast.appendChild(text)
return toast
}
/**
* Creates the Twitter import button
* @param onClick - Click handler for the button
* @returns HTMLElement - The button element
*/
export function createTwitterImportButton(onClick: () => void): HTMLElement {
const button = document.createElement("div")
button.id = ELEMENT_IDS.TWITTER_IMPORT_BUTTON
button.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
z-index: 2147483646;
background: #ffffff;
color: black;
border: none;
border-radius: 50px;
padding: 12px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
`
const iconUrl = browser.runtime.getURL("/icon-16.png")
button.innerHTML = `
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
`
button.addEventListener("mouseenter", () => {
button.style.transform = "scale(1.05)"
button.style.boxShadow = "0 4px 12px rgba(29, 155, 240, 0.4)"
})
button.addEventListener("mouseleave", () => {
button.style.transform = "scale(1)"
button.style.boxShadow = "0 2px 8px rgba(29, 155, 240, 0.3)"
})
button.addEventListener("click", onClick)
return button
}
/**
* Creates the Twitter import UI dialog
* @param onClose - Close handler
* @param onImport - Import handler
* @param isAuthenticated - Whether user is authenticated
* @returns HTMLElement - The dialog element
*/
export function createTwitterImportUI(
onClose: () => void,
onImport: () => void,
isAuthenticated: boolean,
): HTMLElement {
const container = document.createElement("div")
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 2147483647;
background: #ffffff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 280px;
max-width: 400px;
border: 1px solid #e1e5e9;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
`
container.innerHTML = `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<div style="display: flex; align-items: center; gap: 8px;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#1d9bf0">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #0f1419;">
Import Twitter Bookmarks
</h3>
</div>
<button id="${ELEMENT_IDS.TWITTER_CLOSE_BTN}" style="background: none; border: none; cursor: pointer; padding: 4px; border-radius: 4px; color: #536471;">
</button>
</div>
${
isAuthenticated
? `
<div>
<p style="color: #536471; font-size: 14px; margin: 0 0 12px 0; line-height: 1.4;">
This will import all your Twitter bookmarks to Supermemory
</p>
<button id="${ELEMENT_IDS.TWITTER_IMPORT_BTN}" style="width: 100%; background: #1d9bf0; color: white; border: none; border-radius: 20px; padding: 12px 16px; cursor: pointer; font-size: 14px; font-weight: 500; margin-bottom: 12px;">
Import All Bookmarks
</button>
<div id="${ELEMENT_IDS.TWITTER_IMPORT_STATUS}"></div>
</div>
`
: `
<div style="text-align: center;">
<p style="color: #536471; font-size: 14px; margin: 0 0 12px 0;">
Please sign in to supermemory first
</p>
<button id="${ELEMENT_IDS.TWITTER_SIGNIN_BTN}" style="background: #1d9bf0; color: white; border: none; border-radius: 20px; padding: 8px 16px; cursor: pointer; font-size: 14px; font-weight: 500;">
Sign In
</button>
</div>
`
}
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`
// Add event listeners
const closeBtn = container.querySelector(`#${ELEMENT_IDS.TWITTER_CLOSE_BTN}`)
closeBtn?.addEventListener("click", onClose)
const importBtn = container.querySelector(
`#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`,
)
importBtn?.addEventListener("click", onImport)
const signinBtn = container.querySelector(
`#${ELEMENT_IDS.TWITTER_SIGNIN_BTN}`,
)
signinBtn?.addEventListener("click", () => {
browser.tabs.create({ url: `${API_ENDPOINTS.SUPERMEMORY_WEB}/login` })
})
return container
}
/**
* Creates a save tweet element button for Twitter/X
* @param onClick - Click handler for the button
* @returns HTMLElement - The save button element
*/
export function createSaveTweetElement(onClick: () => void): HTMLElement {
const iconButton = document.createElement("div")
iconButton.style.cssText = `
display: inline-flex;
align-items: flex-end;
opacity: 0.7;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
margin-right: 10px;
margin-bottom: 2px;
z-index: 1000;
`
const iconFileName = "/icon-16.png"
const iconUrl = browser.runtime.getURL(iconFileName)
iconButton.innerHTML = `
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
`
iconButton.addEventListener("mouseenter", () => {
iconButton.style.opacity = "1"
})
iconButton.addEventListener("mouseleave", () => {
iconButton.style.opacity = "0.7"
})
iconButton.addEventListener("click", (event) => {
event.stopPropagation()
event.preventDefault()
onClick()
})
return iconButton
}
/**
* Creates a save element button for ChatGPT input bar
* @param onClick - Click handler for the button
* @returns HTMLElement - The save button element
*/
export function createChatGPTInputBarElement(onClick: () => void): HTMLElement {
const iconButton = document.createElement("div")
iconButton.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
transition: opacity 0.2s ease;
border-radius: 50%;
`
// Use appropriate icon based on theme
const iconFileName = "/icon-16.png"
const iconUrl = browser.runtime.getURL(iconFileName)
iconButton.innerHTML = `
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 50%;" />
`
iconButton.addEventListener("mouseenter", () => {
iconButton.style.opacity = "0.8"
})
iconButton.addEventListener("mouseleave", () => {
iconButton.style.opacity = "1"
})
iconButton.addEventListener("click", (event) => {
event.stopPropagation()
event.preventDefault()
onClick()
})
return iconButton
}
/**
* Utility functions for DOM manipulation
*/
export const DOMUtils = {
/**
* Check if current page is on specified domains
* @param domains - Array of domain names to check
* @returns boolean
*/
isOnDomain(domains: readonly string[]): boolean {
return domains.includes(window.location.hostname)
},
/**
* Detect if the page is in dark mode based on color-scheme style
* @returns boolean - true if dark mode, false if light mode
*/
isDarkMode(): boolean {
const htmlElement = document.documentElement
const style = htmlElement.getAttribute("style")
return style?.includes("color-scheme: dark") || false
},
/**
* Check if element exists in DOM
* @param id - Element ID to check
* @returns boolean
*/
elementExists(id: string): boolean {
return !!document.getElementById(id)
},
/**
* Remove element from DOM if it exists
* @param id - Element ID to remove
*/
removeElement(id: string): void {
const element = document.getElementById(id)
element?.remove()
},
/**
* Show toast notification with auto-dismiss
* @param state - Toast state
* @param duration - Duration to show toast (default from config)
* @returns The toast element
*/
showToast(
state: ToastState,
duration: number = UI_CONFIG.TOAST_DURATION,
): HTMLElement {
// Remove all existing toasts more aggressively
const existingToasts = document.querySelectorAll(
`#${ELEMENT_IDS.SUPERMEMORY_TOAST}`,
)
existingToasts.forEach((toast) => {
toast.remove()
})
const toast = createToast(state)
document.body.appendChild(toast)
// Auto-dismiss for success and error states
if (state === "success" || state === "error") {
setTimeout(() => {
if (document.body.contains(toast)) {
toast.style.animation = "fadeOut 0.3s ease-out"
setTimeout(() => {
if (document.body.contains(toast)) {
toast.remove()
}
}, 300)
}
}, duration)
}
return toast
},
}

View file

@ -0,0 +1,40 @@
import tailwindcss from "@tailwindcss/vite"
import { defineConfig, type WxtViteConfig } from "wxt"
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ["@wxt-dev/module-react"],
vite: () =>
({
plugins: [tailwindcss()],
}) as WxtViteConfig,
manifest: {
name: "supermemory",
homepage_url: "https://supermemory.ai",
permissions: [
"contextMenus",
"storage",
"scripting",
"activeTab",
"webRequest",
"tabs",
],
host_permissions: [
"*://x.com/*",
"*://twitter.com/*",
"*://supermemory.ai/*",
"*://api.supermemory.ai/*",
"*://chatgpt.com/*",
"*://chat.openai.com/*",
],
web_accessible_resources: [
{
resources: ["icon-16.png", "fonts/*.ttf"],
matches: ["<all_urls>"],
},
],
},
webExt: {
disabled: true,
},
})

View file

@ -690,11 +690,24 @@ export default function Page() {
retry: 1, // Only retry once on failure
});
useEffect(() => {
// save the token for chrome extension
const url = new URL(window.location.href);
const rawToken = url.searchParams.get("token");
if (rawToken) {
const encodedToken = encodeURIComponent(rawToken);
window.postMessage({ token: encodedToken }, "*");
url.searchParams.delete("token");
window.history.replaceState({}, "", url.toString());
}
}, []);
useEffect(() => {
if (waitlistStatus && !waitlistStatus.accessGranted) {
router.push("/waitlist");
}
}, []);
}, [waitlistStatus, router]);
// Show loading state while checking authentication and waitlist status
if (!user || isCheckingWaitlist) {

1859
bun.lock

File diff suppressed because it is too large Load diff