feat: fix interaction and improve Design for extension (#679)

### TL;DR

Redesigned the browser extension UI with a dark theme and improved the Twitter bookmarks import experience with a new onboarding flow.

### What changed?

- Added a new `RightArrow` icon component for UI navigation
- Completely redesigned the popup UI with a dark theme and improved layout
- Enhanced Twitter bookmarks import functionality:
  - Added an onboarding toast that appears the first time a user visits the bookmarks page
  - Implemented a persistent import intent system that automatically opens the import modal when navigating to the bookmarks page
  - Created a progress toast to show import status
  - Improved folder import UI
- Updated the extension icon and added a new logo SVG
- Improved the project selection modal with better styling
This commit is contained in:
MaheshtheDev 2026-01-18 04:06:36 +00:00
parent acdb422635
commit 3fa72c4ec7
11 changed files with 856 additions and 270 deletions

View file

@ -0,0 +1,18 @@
export function RightArrow({ className }: { className?: string }) {
return (
<svg
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Right arrow</title>
<path
d="M-1.26511e-05 5.82399V4.53599H7.81199L3.90599 0.895994L4.78799 -6.19888e-06L9.79999 4.77399V5.54399L4.78799 10.332L3.90599 9.43599L7.78399 5.82399H-1.26511e-05Z"
fill="#737373"
/>
</svg>
)
}

View file

@ -15,6 +15,7 @@ import { initializeT3 } from "./t3"
import {
handleTwitterNavigation,
initializeTwitter,
openImportModal,
updateTwitterImportUI,
} from "./twitter"
@ -29,6 +30,8 @@ export default defineContentScript({
await saveMemory()
} else if (message.action === MESSAGE_TYPES.OPEN_SEARCH_PANEL) {
handleOpenSearchPanel(message.data as string)
} else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
await openImportModal()
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
updateTwitterImportUI(message)
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {

View file

@ -3,10 +3,11 @@ import {
ELEMENT_IDS,
MESSAGE_TYPES,
POSTHOG_EVENT_KEY,
STORAGE_KEYS,
UI_CONFIG,
} from "../../utils/constants"
import { trackEvent } from "../../utils/posthog"
import {
createTwitterImportButton,
createProjectSelectionModal,
createSaveTweetElement,
DOMUtils,
@ -27,46 +28,105 @@ async function loadSpaceGroteskFonts(): Promise<void> {
await document.fonts.ready
}
export function initializeTwitter() {
/**
* Check if import intent is valid (exists and not expired)
*/
async function checkAndConsumeImportIntent(): Promise<boolean> {
try {
const result = await browser.storage.local.get(
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
)
const intentUntil = result[
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL
] as number | undefined
if (intentUntil && Date.now() < intentUntil) {
await browser.storage.local.remove(
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
)
return true
}
return false
} catch (error) {
console.error("Error checking import intent:", error)
return false
}
}
/**
* Check if onboarding toast has been shown before
*/
async function hasOnboardingBeenShown(): Promise<boolean> {
try {
const result = await browser.storage.local.get(
STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN,
)
return !!result[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]
} catch (error) {
console.error("Error checking onboarding status:", error)
return true // Default to true to avoid showing toast on error
}
}
/**
* Mark onboarding toast as shown
*/
async function markOnboardingAsShown(): Promise<void> {
try {
await browser.storage.local.set({
[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]: true,
})
} catch (error) {
console.error("Error marking onboarding as shown:", error)
}
}
export async function initializeTwitter() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
// Initial setup
if (window.location.pathname === "/i/bookmarks") {
setTimeout(() => {
addTwitterImportButton()
addTwitterImportButtonForFolders()
setTimeout(async () => {
if (window.location.pathname === "/i/bookmarks") {
await handleBookmarksPageLoad()
}
}, 2000)
} else {
// Remove button if not on bookmarks page
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
}
// Clean up any injected UI if navigating away
removeAllTwitterUI()
}
}
function addTwitterImportButton() {
/**
* Handle what to show when user lands on bookmarks page
*/
async function handleBookmarksPageLoad() {
if (window.location.pathname !== "/i/bookmarks") {
return
}
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
addTwitterImportButtonForFolders() // Add buttons to bookmark folders
const hasIntent = await checkAndConsumeImportIntent()
if (hasIntent) {
await openImportModal()
return
}
const button = createTwitterImportButton(async () => {
try {
await handleAllBookmarksImportClick()
} catch (error) {
console.error("Error starting import:", error)
}
})
const onboardingShown = await hasOnboardingBeenShown()
document.body.appendChild(button)
if (!onboardingShown) {
await showOnboardingToast()
await markOnboardingAsShown()
}
}
async function handleAllBookmarksImportClick() {
/**
* Opens the import modal and handles the import flow
*/
export async function openImportModal() {
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.FETCH_PROJECTS,
@ -85,14 +145,13 @@ async function handleAllBookmarksImportClick() {
await showAllBookmarksProjectModal(projects)
}
} catch (error) {
console.error("Error handling all bookmarks import:", error)
console.error("Error opening import modal:", error)
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
})
}
}
async function showAllBookmarksProjectModal(
projects: Array<{ id: string; name: string; containerTag: string }>,
) {
@ -124,6 +183,420 @@ async function showAllBookmarksProjectModal(
document.body.appendChild(modal)
}
/**
* Shows the one-time onboarding toast with progress bar
*/
async function showOnboardingToast() {
await loadSpaceGroteskFonts()
// Remove any existing toast
const existingToast = document.getElementById(
ELEMENT_IDS.TWITTER_ONBOARDING_TOAST,
)
if (existingToast) {
existingToast.remove()
}
const duration = UI_CONFIG.ONBOARDING_TOAST_DURATION
// Create toast container
const toast = document.createElement("div")
toast.id = ELEMENT_IDS.TWITTER_ONBOARDING_TOAST
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
background: #ffffff;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #374151;
min-width: 320px;
max-width: 380px;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
animation: smSlideInUp 0.3s ease-out;
overflow: hidden;
`
// Add keyframe animations if not already present
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
const style = document.createElement("style")
style.id = "supermemory-onboarding-toast-styles"
style.textContent = `
@keyframes smSlideInUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes smFadeOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100%); opacity: 0; }
}
@keyframes smProgressGrow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes smPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`
document.head.appendChild(style)
}
// Header with icon, text and close button
const header = document.createElement("div")
header.style.cssText =
"display: flex; align-items: flex-start; gap: 12px; position: relative;"
const iconUrl = browser.runtime.getURL("/icon-16.png")
const icon = document.createElement("img")
icon.src = iconUrl
icon.alt = "Supermemory"
icon.style.cssText = "width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;"
const textContainer = document.createElement("div")
textContainer.style.cssText = "display: flex; flex-direction: column; gap: 4px; flex: 1;"
const title = document.createElement("span")
title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;"
title.textContent = "Import X/Twitter Bookmarks"
const description = document.createElement("span")
description.style.cssText = "font-size: 13px; color: #6b7280; line-height: 1.4;"
description.textContent =
"You can import all your Twitter bookmarks to Supermemory with one click."
textContainer.appendChild(title)
textContainer.appendChild(description)
// Close button
const closeButton = document.createElement("button")
closeButton.setAttribute("aria-label", "Close onboarding toast")
closeButton.style.cssText = `
position: absolute;
top: 0;
right: 0;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
`
closeButton.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`
closeButton.addEventListener("mouseenter", () => {
closeButton.style.backgroundColor = "#f3f4f6"
})
closeButton.addEventListener("mouseleave", () => {
closeButton.style.backgroundColor = "transparent"
})
closeButton.addEventListener("click", () => {
dismissToast(toast)
})
header.appendChild(icon)
header.appendChild(textContainer)
header.appendChild(closeButton)
// Action buttons
const buttonsContainer = document.createElement("div")
buttonsContainer.style.cssText = "display: flex; gap: 8px; margin-top: 4px;"
const importButton = document.createElement("button")
importButton.style.cssText = `
padding: 8px 16px;
border: none;
border-radius: 8px;
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
font-family: inherit;
`
importButton.textContent = "Import now"
importButton.addEventListener("mouseenter", () => {
importButton.style.opacity = "0.9"
})
importButton.addEventListener("mouseleave", () => {
importButton.style.opacity = "1"
})
importButton.addEventListener("click", async () => {
dismissToast(toast)
await openImportModal()
})
const learnMoreButton = document.createElement("button")
learnMoreButton.style.cssText = `
padding: 8px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: transparent;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
`
learnMoreButton.textContent = "Learn more"
learnMoreButton.addEventListener("mouseenter", () => {
learnMoreButton.style.backgroundColor = "#f9fafb"
})
learnMoreButton.addEventListener("mouseleave", () => {
learnMoreButton.style.backgroundColor = "transparent"
})
learnMoreButton.addEventListener("click", () => {
window.open(
"https://docs.supermemory.ai/connectors/twitter",
"_blank",
)
})
buttonsContainer.appendChild(importButton)
buttonsContainer.appendChild(learnMoreButton)
// Progress bar container
const progressBarContainer = document.createElement("div")
progressBarContainer.setAttribute("role", "progressbar")
progressBarContainer.setAttribute("aria-valuemin", "0")
progressBarContainer.setAttribute("aria-valuemax", "100")
progressBarContainer.setAttribute("aria-valuenow", "0")
progressBarContainer.setAttribute("aria-label", "Onboarding toast auto-dismiss progress")
progressBarContainer.style.cssText = `
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: #e5e7eb;
`
const progressBar = document.createElement("div")
progressBar.style.cssText = `
height: 100%;
background: linear-gradient(90deg, #0ff0d2, #5bd3fb, #1e0ff0);
transform-origin: left;
animation: smProgressGrow ${duration}ms linear forwards;
`
// Update progress bar ARIA value as animation progresses
const startTime = Date.now()
const updateProgress = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(100, Math.round((elapsed / duration) * 100))
progressBarContainer.setAttribute("aria-valuenow", String(progress))
if (progress < 100) {
requestAnimationFrame(updateProgress)
}
}
requestAnimationFrame(updateProgress)
progressBarContainer.appendChild(progressBar)
// Assemble toast
toast.appendChild(header)
toast.appendChild(buttonsContainer)
toast.appendChild(progressBarContainer)
document.body.appendChild(toast)
// Auto-dismiss after duration
setTimeout(() => {
if (document.body.contains(toast)) {
dismissToast(toast)
}
}, duration)
}
/**
* Dismiss the toast with animation
*/
function dismissToast(toast: HTMLElement) {
toast.style.animation = "smFadeOut 0.3s ease-out forwards"
setTimeout(() => {
if (document.body.contains(toast)) {
toast.remove()
}
}, 300)
}
/**
* Remove all Twitter-specific injected UI
*/
function removeAllTwitterUI() {
// Remove import button (legacy)
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
}
// Remove onboarding toast
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)
}
// Remove import progress toast
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
}
// Remove any folder buttons
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
button.remove()
})
}
/**
* Shows or updates the import progress toast in the bottom-right
*/
function showOrUpdateImportProgressToast(message: string, isComplete = false) {
let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
if (!toast) {
// Ensure animation styles are available
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
const style = document.createElement("style")
style.id = "supermemory-onboarding-toast-styles"
style.textContent = `
@keyframes smSlideInUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes smFadeOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100%); opacity: 0; }
}
@keyframes smPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`
document.head.appendChild(style)
}
// Create new toast
toast = document.createElement("div")
toast.id = ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
background: #ffffff;
border-radius: 12px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #374151;
min-width: 280px;
max-width: 360px;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
animation: smSlideInUp 0.3s ease-out;
`
const iconUrl = browser.runtime.getURL("/icon-16.png")
const icon = document.createElement("img")
icon.src = iconUrl
icon.alt = "Supermemory"
icon.id = "sm-import-progress-icon"
icon.style.cssText =
"width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; animation: smPulse 1.5s ease-in-out infinite;"
const textSpan = document.createElement("span")
textSpan.id = "sm-import-progress-text"
textSpan.style.cssText = "font-weight: 500; flex: 1;"
textSpan.textContent = message
toast.appendChild(icon)
toast.appendChild(textSpan)
document.body.appendChild(toast)
} else {
// Update existing toast
const textSpan = toast.querySelector(
"#sm-import-progress-text",
) as HTMLSpanElement
if (textSpan) {
textSpan.textContent = message
}
}
// Style for completion
if (isComplete) {
const icon = toast.querySelector(
"#sm-import-progress-icon",
) as HTMLImageElement
if (icon) {
icon.style.animation = "none"
icon.style.opacity = "1"
}
const textSpan = toast.querySelector(
"#sm-import-progress-text",
) as HTMLSpanElement
if (textSpan) {
textSpan.style.color = "#059669"
}
// Auto-dismiss after 4 seconds on completion
setTimeout(() => {
const existingToast = document.getElementById(
ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST,
)
if (existingToast) {
dismissToast(existingToast)
}
}, 4000)
}
}
export function updateTwitterImportUI(message: {
type: string
importedMessage?: string
totalImported?: number
}) {
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) {
showOrUpdateImportProgressToast(message.importedMessage, false)
}
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
showOrUpdateImportProgressToast(
`✓ Imported ${message.totalImported} tweets!`,
true,
)
}
}
export async function handleTwitterNavigation() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
if (window.location.pathname === "/i/bookmarks") {
addTwitterImportButtonForFolders()
await handleBookmarksPageLoad()
} else {
removeAllTwitterUI()
}
}
/**
* Adds import buttons to bookmark folders
*/
function addTwitterImportButtonForFolders() {
if (window.location.pathname !== "/i/bookmarks") {
return
@ -138,6 +611,9 @@ function addTwitterImportButtonForFolders() {
})
}
/**
* Adds an import button to a bookmark folder element
*/
function addButtonToElement(element: HTMLElement) {
if (element.querySelector("[data-supermemory-button]")) {
return
@ -148,9 +624,8 @@ function addButtonToElement(element: HTMLElement) {
const button = createSaveTweetElement(async () => {
const url = element.getAttribute("href")
const bookmarkCollectionId = url?.split("/").pop()
console.log("Bookmark collection ID:", bookmarkCollectionId)
if (bookmarkCollectionId) {
await showProjectSelectionModal(bookmarkCollectionId)
await showFolderProjectSelectionModal(bookmarkCollectionId)
}
})
@ -164,132 +639,55 @@ function addButtonToElement(element: HTMLElement) {
element.style.padding = "10px"
}
export function updateTwitterImportUI(message: {
type: string
importedMessage?: string
totalImported?: number
}) {
const importButton = document.getElementById(
ELEMENT_IDS.TWITTER_IMPORT_BUTTON,
)
if (!importButton) return
const existingImg = importButton.querySelector("img")
if (existingImg) {
existingImg.remove()
const iconUrl = browser.runtime.getURL("/icon-16.png")
importButton.style.backgroundImage = `url("${iconUrl}")`
importButton.style.backgroundRepeat = "no-repeat"
importButton.style.backgroundSize = "20px 20px"
importButton.style.backgroundPosition = "8px center"
importButton.style.padding = "10px 16px 10px 32px"
}
let textSpan = importButton.querySelector(
"#sm-import-text",
) as HTMLSpanElement
if (!textSpan) {
textSpan = document.createElement("span")
textSpan.id = "sm-import-text"
textSpan.style.cssText = "font-weight: 500; font-size: 14px;"
importButton.appendChild(textSpan)
}
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
textSpan.textContent = message.importedMessage || ""
importButton.style.cursor = "default"
}
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
textSpan.textContent = `✓ Imported ${message.totalImported} tweets!`
textSpan.style.color = "#059669"
setTimeout(() => {
textSpan.textContent = "Import Bookmarks"
textSpan.style.color = ""
importButton.style.cursor = "pointer"
}, 3000)
}
}
export function handleTwitterNavigation() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return
}
if (window.location.pathname === "/i/bookmarks") {
addTwitterImportButton()
addTwitterImportButtonForFolders()
} else {
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
}
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
button.remove()
})
}
}
/**
* Shows the project selection modal for folder imports
* @param bookmarkCollectionId - The ID of the bookmark collection to import
*/
async function showProjectSelectionModal(bookmarkCollectionId: string) {
try {
const modal = createProjectSelectionModal(
[],
async (selectedProject) => {
modal.remove()
async function showFolderProjectSelectionModal(bookmarkCollectionId: string) {
await loadSpaceGroteskFonts()
try {
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
isFolderImport: true,
bookmarkCollectionId: bookmarkCollectionId,
selectedProject: selectedProject,
})
} catch (error) {
console.error("Error importing bookmarks:", error)
}
},
() => {
modal.remove()
},
)
const modal = createProjectSelectionModal(
[],
async (selectedProject) => {
modal.remove()
document.body.appendChild(modal)
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.FETCH_PROJECTS,
})
if (response.success && response.data) {
const projects = response.data
if (projects.length === 0) {
console.warn("No projects available for import")
updateModalWithProjects(modal, [])
} else {
updateModalWithProjects(modal, projects)
}
} else {
console.error("Failed to fetch projects:", response.error)
updateModalWithProjects(modal, [])
try {
await browser.runtime.sendMessage({
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
isFolderImport: true,
bookmarkCollectionId: bookmarkCollectionId,
selectedProject: selectedProject,
})
} catch (error) {
console.error("Error importing bookmarks:", error)
}
} catch (error) {
console.error("Error fetching projects:", error)
},
() => {
modal.remove()
},
)
document.body.appendChild(modal)
try {
const response = await browser.runtime.sendMessage({
action: MESSAGE_TYPES.FETCH_PROJECTS,
})
if (response.success && response.data) {
const projects = response.data
updateModalWithProjects(modal, projects)
} else {
console.error("Failed to fetch projects:", response.error)
updateModalWithProjects(modal, [])
}
} catch (error) {
console.error("Error showing project selection modal:", error)
console.error("Error fetching projects:", error)
updateModalWithProjects(modal, [])
}
}
/**
* Updates the modal with fetched projects
* @param modal - The modal element
* @param projects - Array of projects to populate the dropdown
*/
function updateModalWithProjects(
modal: HTMLElement,
@ -316,10 +714,10 @@ function updateModalWithProjects(
importButton.disabled = true
importButton.style.cssText = `
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #d1d5db;
color: #9ca3af;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
font-weight: 500;
cursor: not-allowed;

View file

@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import "./App.css"
import { validateAuthToken } from "../../utils/api"
import { MESSAGE_TYPES } from "../../utils/constants"
import { MESSAGE_TYPES, STORAGE_KEYS, UI_CONFIG } from "../../utils/constants"
import {
useDefaultProject,
useProjects,
@ -17,6 +17,7 @@ import {
userData as userDataStorage,
} from "../../utils/storage"
import type { Project } from "../../utils/types"
import { RightArrow } from "@/components/icons"
const Tooltip = ({
children,
@ -181,6 +182,11 @@ function App() {
}
}, [defaultProject, projects, setDefaultProjectMutation])
// biome-ignore lint/correctness/useExhaustiveDependencies: close space selector when tab changes
useEffect(() => {
setShowProjectSelector(false)
}, [activeTab])
const handleSaveCurrentPage = async () => {
setSaving(true)
@ -262,25 +268,51 @@ function App() {
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 shrink-0"
src="./dark-transparent.svg"
style={{ width: "80%", height: "45px" }}
/>
<div
className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] rounded-lg relative overflow-hidden"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
boxShadow:
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
}}
>
<div
id="popup-header"
className="flex items-center justify-between p-2.5 relative"
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 shrink-0 rounded-[3.75px] overflow-hidden relative"
style={{ boxShadow: "inset 0px 1px 3.75px 0px #000" }}
>
<img
alt="supermemory"
src="./icon-48.png"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
/>
</div>
<div className="flex flex-col">
<span className="text-[11px] font-medium text-[#737373] leading-normal">
Your
</span>
<img
alt="supermemory"
src="./logo-fullmark.svg"
className="h-[14.5px] w-auto"
/>
</div>
</div>
</div>
<div className="p-4 text-black min-h-[300px] flex items-center justify-center">
<div className="flex items-center gap-3 text-base text-black">
<div className="p-4 min-h-[300px] flex items-center justify-center">
<div className="flex items-center gap-3 text-sm text-[#737373]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
className="text-black"
className="text-[#737373]"
aria-hidden="true"
>
<path d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z">
@ -294,7 +326,7 @@ function App() {
</path>
</svg>
<span className="font-semibold">Loading...</span>
<span className="font-medium">Loading...</span>
</div>
</div>
</div>
@ -303,26 +335,56 @@ function App() {
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"
className="w-80 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] rounded-lg relative overflow-hidden p-4"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
boxShadow:
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
}}
>
<div className="flex items-center justify-between gap-3 p-2.5 relative">
<div className="text-white text-lg font-semibold ml-2">supermemory</div>
<div
id="popup-header"
className="flex items-center justify-between p-2.5 relative"
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 shrink-0 rounded-[3.75px] overflow-hidden relative"
style={{ boxShadow: "inset 0px 1px 3.75px 0px #000" }}
>
<img
alt="supermemory"
src="./icon-48.png"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
/>
</div>
<div className="flex flex-col">
<span className="text-[11px] font-medium text-[#737373] leading-normal">
{(() => {
const name =
userData?.name?.split(" ")[0] ||
userData?.email?.split("@")[0]
if (!name) return "Your"
return name.endsWith("s") ? `${name}'` : `${name}'s`
})()}
</span>
<img
alt="supermemory"
src="./logo-fullmark.svg"
className="h-[14.5px] w-auto"
/>
</div>
</div>
{userSignedIn && (
<button
className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200"
className="bg-transparent border-none cursor-pointer p-1 rounded transition-colors duration-200"
onClick={handleSignOut}
title="Logout"
aria-label="Logout"
type="button"
>
<svg
width="19"
height="18"
viewBox="0 0 19 18"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
@ -330,15 +392,15 @@ function App() {
<path
d="M17 9H7.5M15 12L18 9L15 6M10 4V3C10 2.46957 9.78929 1.96086 9.41421 1.58579C9.03914 1.21071 8.53043 1 8 1H3C2.46957 1 1.96086 1.21071 1.58579 1.58579C1.21071 1.96086 1 2.46957 1 3V15C1 15.5304 1.21071 16.0391 1.58579 16.4142C1.96086 16.7893 2.46957 17 3 17H8C8.53043 17 9.03914 16.7893 9.41421 16.4142C9.78929 16.0391 10 15.5304 10 15V14"
stroke="#FAFAFA"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
<div className="p-4 min-h-[250px] pt-1">
<div className="min-h-[250px] pt-1">
{userSignedIn ? (
<div className="text-left">
{/* Tab Navigation */}
@ -408,43 +470,119 @@ function App() {
</div>
</div>
{/* Project Selection */}
<div className="mb-0">
<button
className="w-full bg-[#5B7EF50A] border-none p-4 cursor-pointer text-left rounded-xl flex justify-between items-center transition-colors duration-200 hover:bg-[#5B7EF520]"
onClick={handleShowProjectSelector}
type="button"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
>
{/* Space Selection */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between pl-1">
<span className="text-sm font-medium text-[#737373]">
Save to project:
Save to Space
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
{showProjectSelector && (
<button
id="close-space-selector"
className="bg-transparent border-none cursor-pointer p-0 text-[#737373] hover:text-white transition-colors"
onClick={() => setShowProjectSelector(false)}
type="button"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{showProjectSelector ? (
<div className="flex flex-col gap-1 max-h-[180px] overflow-y-auto">
{loadingProjects ? (
<div className="h-11 flex items-center justify-center text-sm text-[#737373]">
Loading spaces...
</div>
) : (
projects.map((project) => (
<button
id={`space-option-${project.id}`}
className={`w-full h-11 flex items-center justify-between px-4 rounded-lg bg-transparent border-none cursor-pointer text-left transition-colors duration-200 hover:bg-[#5B7EF510] ${
defaultProject?.id === project.id
? "bg-[#5B7EF50A]"
: ""
}`}
style={
defaultProject?.id === project.id
? {
boxShadow:
"2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}
: undefined
}
key={project.id}
onClick={() => handleProjectSelect(project)}
type="button"
>
<span className="text-sm font-normal text-[rgba(255,255,255,0.94)] overflow-hidden text-ellipsis whitespace-nowrap tracking-tight">
{project.name}
</span>
{defaultProject?.id === project.id && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white shrink-0"
>
<title>Selected</title>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))
)}
</div>
) : (
<button
id="space-selector-trigger"
className="w-full h-11 flex items-center justify-between px-4 rounded-lg bg-[#5B7EF50A] border-none cursor-pointer text-left transition-colors duration-200 hover:bg-[#5B7EF520]"
onClick={handleShowProjectSelector}
type="button"
style={{
boxShadow:
"2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
>
<span className="text-sm font-normal text-[rgba(255,255,255,0.94)] overflow-hidden text-ellipsis whitespace-nowrap tracking-tight">
{defaultProject
? defaultProject.name
: "Default Project"}
: "Select a space"}
</span>
<svg
aria-label="Select project"
className="text-white shrink-0 transition-transform duration-200 hover:text-gray-700 transform rotate-90"
fill="none"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
className="text-white shrink-0"
>
<title>Select project</title>
<path d="M9 18l6-6-6-6" />
<title>Expand</title>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</button>
</button>
)}
</div>
{/* Save Button at Bottom */}
@ -496,7 +634,7 @@ function App() {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<button
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
@ -524,34 +662,79 @@ function App() {
Import ChatGPT Memories
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
open 'manage', save your memories to supermemory
open 'manage' &gt; save your memories to supermemory
</p>
</div>
<RightArrow className="size-4" />
</button>
</div>
<div className="flex flex-col gap-2">
<button
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
style={{
boxShadow:
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
}}
onClick={async () => {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
const targetUrl = "https://x.com/i/bookmarks"
if (activeTab?.url === targetUrl) {
return
}
try {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
})
await chrome.tabs.create({
url: targetUrl,
})
const isOnBookmarksPage =
activeTab?.url?.includes("x.com/i/bookmarks") ||
activeTab?.url?.includes("twitter.com/i/bookmarks")
if (isOnBookmarksPage && activeTab?.id) {
try {
await chrome.tabs.sendMessage(activeTab.id, {
action: MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL,
})
} catch (error) {
// Content script may not be loaded yet, fall back to intent-based approach
console.error(
"Failed to send message to content script:",
error,
)
const intentExpiry =
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
await chrome.storage.local.set({
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
intentExpiry,
})
await chrome.tabs.create({
url: targetUrl,
})
}
} else {
const intentExpiry =
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
await chrome.storage.local.set({
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
intentExpiry,
})
await chrome.tabs.create({
url: targetUrl,
})
}
} catch (error) {
console.error("Error opening Twitter import:", error)
// Fallback: try to open the bookmarks page anyway
try {
await chrome.tabs.create({
url: targetUrl,
})
} catch (fallbackError) {
console.error(
"Failed to open bookmarks page:",
fallbackError,
)
}
}
}}
type="button"
>
@ -570,9 +753,10 @@ function App() {
Import X/Twitter Bookmarks
</p>
<p className="m-0 text-[14px] text-[#737373] leading-tight">
Click on supermemory on top right to import bookmarks
Opens import dialog automatically
</p>
</div>
<RightArrow className="size-4" />
</button>
</div>
</div>
@ -588,7 +772,9 @@ function App() {
</div>
) : userData?.email ? (
<>
<span className="font-medium text-base">Email</span>
<span className="font-medium text-base text-white">
Email
</span>
<span
className="text-sm text-[#525966] p-3 rounded-xl bg-[#5B7EF50A]"
style={{
@ -608,7 +794,7 @@ function App() {
{/* Chat Integration Section */}
<div className="mb-4">
<h3 className="text-base font-semibold mb-3">
<h3 className="text-base font-semibold mb-3 text-white">
Chat Integration
</h3>
<div className="flex items-center justify-between p-3 rounded-xl bg-[#5B7EF50A] mb-3">
@ -654,53 +840,6 @@ function App() {
</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 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 wrap-break-word 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">
@ -718,18 +857,18 @@ function App() {
</div>
) : (
<div className="mb-8">
<h2 className="m-0 mb-4 text-sm font-normal text-black leading-tight">
<h2 className="m-0 mb-4 text-sm font-normal 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">
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] 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">
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] 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">
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] before:font-bold">
Import your ChatGPT Memories
</li>
</ul>
@ -737,7 +876,7 @@ function App() {
)}
<div className="mt-8">
<p className="m-0 mb-4 text-sm text-gray-500">
<p className="m-0 mb-4 text-sm text-[#737373]">
Having trouble logging in?{" "}
<button
className="bg-transparent border-none text-blue-500 cursor-pointer underline text-sm p-0 hover:text-blue-700"

View file

@ -7,6 +7,7 @@
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
border: 1px solid #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
@ -18,7 +19,6 @@
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -114,7 +114,7 @@ export async function validateAuthToken(): Promise<boolean> {
/**
* Get user data from storage
*/
export async function getUserData(): Promise<{ email?: string } | null> {
export async function getUserData(): Promise<{ email?: string; name?: string } | null> {
try {
return (await userData.getValue()) || null
} catch (error) {

View file

@ -15,6 +15,8 @@ export const API_ENDPOINTS = {
*/
export const ELEMENT_IDS = {
TWITTER_IMPORT_BUTTON: "sm-twitter-import-button",
TWITTER_ONBOARDING_TOAST: "sm-twitter-onboarding-toast",
TWITTER_IMPORT_PROGRESS_TOAST: "sm-twitter-import-progress-toast",
SUPERMEMORY_TOAST: "sm-toast",
SUPERMEMORY_SAVE_BUTTON: "sm-save-button",
SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
@ -26,12 +28,22 @@ export const ELEMENT_IDS = {
SELECTION_SEARCH_PANEL: "sm-selection-search-panel",
} as const
/**
* Storage Keys for local
*/
export const STORAGE_KEYS = {
TWITTER_BOOKMARKS_ONBOARDING_SEEN: "sm_twitter_bookmarks_onboarding_seen",
TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL: "sm_twitter_bookmarks_import_intent_until",
} as const
/**
* UI Configuration
*/
export const UI_CONFIG = {
BUTTON_SHOW_DELAY: 2000, // milliseconds
TOAST_DURATION: 3000, // milliseconds
ONBOARDING_TOAST_DURATION: 6000, // milliseconds (6 seconds for progress bar)
IMPORT_INTENT_TTL: 2 * 60 * 1000, // 2 minutes TTL for import intent
RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
PAGINATION_DELAY: 1000, // 1 second between requests
AUTO_SEARCH_DEBOUNCE_DELAY: 1500, // milliseconds to wait after user stops typing
@ -75,6 +87,7 @@ export const MESSAGE_TYPES = {
FETCH_PROJECTS: "sm-fetch-projects",
SEARCH_SELECTION: "sm-search-selection",
OPEN_SEARCH_PANEL: "sm-open-search-panel",
TWITTER_IMPORT_OPEN_MODAL: "sm-twitter-import-open-modal",
} as const
export const CONTEXT_MENU_IDS = {

View file

@ -545,10 +545,10 @@ export function createProjectSelectionModal(
importButton.textContent = "Import"
importButton.style.cssText = `
padding: 10px 16px;
border: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: #d1d5db;
color: #9ca3af;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
font-weight: 500;
cursor: not-allowed;
@ -577,10 +577,10 @@ export function createProjectSelectionModal(
importButton.disabled = true
importButton.style.cssText = `
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #d1d5db;
color: #9ca3af;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
font-weight: 500;
cursor: not-allowed;

View file

@ -11,7 +11,7 @@ export default defineConfig({
manifest: {
name: "supermemory",
homepage_url: "https://supermemory.ai",
version: "6.0.105",
version: "6.1.0",
permissions: ["contextMenus", "storage", "activeTab", "webRequest", "tabs"],
host_permissions: [
"*://x.com/*",