added twitter bookmarks

This commit is contained in:
Mahesh Sanikommmu 2025-08-21 21:31:50 -07:00
parent bd580f4e66
commit 70bbfe6171
13 changed files with 1420 additions and 190 deletions

View file

@ -1,84 +1,145 @@
import { TwitterImporter, type TwitterImportConfig } from '../utils/twitter-import';
import { captureTwitterTokens } from '../utils/twitter-auth';
import { CONTEXT_MENU_IDS, MESSAGE_TYPES, API_ENDPOINTS, CONTAINER_TAGS } from '../utils/constants';
import type { ExtensionMessage, MemoryPayload } from '../utils/types';
export default defineBackground(() => {
let twitterImporter: TwitterImporter | null = null;
browser.runtime.onInstalled.addListener(() => {
browser.contextMenus.create({
id: 'save-to-supermemory',
id: CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY,
title: 'Save to Supermemory',
contexts: ['selection', 'page', 'link'],
});
});
// 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 === 'save-to-supermemory') {
if (info.menuItemId === CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY) {
if (tab?.id) {
try {
await browser.tabs.sendMessage(tab.id, {
action: 'saveMemory',
action: MESSAGE_TYPES.SAVE_MEMORY,
});
} catch (error) {
console.error('Failed to send message to content script:', error);
console.log('Content script may not be injected on this page');
}
}
}
});
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'saveMemory') {
// 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: any): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
const result = await browser.storage.local.get(['bearerToken']);
const bearerToken = result.bearerToken;
if (!bearerToken) {
return { success: false, error: 'No authentication token found' };
}
const payload: MemoryPayload = {
containerTags: [CONTAINER_TAGS.DEFAULT_PROJECT],
content: data.highlightedText + '\n\n' + data.html + '\n\n' + data?.url,
metadata: { sm_source: 'consumer' },
};
const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}/v3/memories`, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
return { success: false, error: `API call failed: ${response.status}` };
}
const responseData = await response.json();
return { success: true, data: responseData };
} 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 browser.storage.local.get(['bearerToken']);
const bearerToken = result.bearerToken;
//const backendURL = 'http://localhost:8787';
const backendURL = 'https://api.supermemory.ai';
if (!bearerToken) {
console.error('No bearer token found');
sendResponse({ success: false, error: 'No authentication token found' });
return;
}
const response = await fetch(`${backendURL}/v3/memories`, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
containerTags: ['sm_project_default'],
content:
message.data?.highlightedText +
'\n\n' +
message.data.html +
'\n\n' +
message.data?.url,
metadata: { sm_source: 'consumer' },
}),
});
if (!response.ok) {
const errorData = await response.text();
console.error('API call failed:', response.status, errorData);
sendResponse({
success: false,
error: `API call failed: ${response.status}`,
});
return;
}
const data = await response.json();
console.log('Memory saved successfully:', data);
sendResponse({ success: true, data });
const result = await saveMemoryToSupermemory(message.data);
sendResponse(result);
} catch (error) {
console.error('Error saving memory:', error);
sendResponse({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
})();
return true;
}
});
});
});

View file

@ -1,21 +1,33 @@
import { createTwitterImportButton, createTwitterImportUI, createSaveTweetElement, DOMUtils } from '../utils/ui-components';
import { DOMAINS, ELEMENT_IDS, MESSAGE_TYPES } from '../utils/constants';
export default defineContentScript({
matches: ['<all_urls>'],
main() {
let currentToast: HTMLElement | null = null;
let twitterImportUI: HTMLElement | null = null;
let isTwitterImportOpen = false;
browser.runtime.onMessage.addListener(async (message) => {
if (message.action === 'showToast') {
showToast(message.state);
} else if (message.action === 'saveMemory') {
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 (window.location.hostname === 'chatgpt.com' || window.location.hostname === 'chat.openai.com') {
if (DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
addSupermemoryButtonToMemoriesDialog();
}
if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
addTwitterImportButton();
//addSaveTweetElement();
}
});
observer.observe(document.body, {
@ -26,8 +38,19 @@ export default defineContentScript({
if (window.location.hostname === 'chatgpt.com' || window.location.hostname === 'chat.openai.com') {
addSupermemoryButtonToMemoriesDialog();
}
if (window.location.hostname === 'x.com' || window.location.hostname === 'twitter.com') {
addTwitterImportButton();
//addSaveTweetElement();
}
};
if (window.location.hostname === 'x.com' || window.location.hostname === 'twitter.com') {
setTimeout(() => {
addTwitterImportButton(); // Wait 2 seconds for page to load
//addSaveTweetElement();
}, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeForMemoriesDialog);
} else {
@ -36,7 +59,7 @@ export default defineContentScript({
async function saveMemory() {
try {
showToast('loading');
DOMUtils.showToast('loading');
const highlightedText = window.getSelection()?.toString() || '';
@ -45,7 +68,7 @@ export default defineContentScript({
const html = document.documentElement.outerHTML;
const response = await browser.runtime.sendMessage({
action: 'saveMemory',
action: MESSAGE_TYPES.SAVE_MEMORY,
data: {
html,
highlightedText,
@ -55,13 +78,13 @@ export default defineContentScript({
console.log('Response from enxtension:', response);
if (response.success) {
showToast('success');
DOMUtils.showToast('success');
} else {
showToast('error');
DOMUtils.showToast('error');
}
} catch (error) {
console.error('Error saving memory:', error);
showToast('error');
DOMUtils.showToast('error');
}
}
@ -126,11 +149,11 @@ export default defineContentScript({
async function saveMemoriesToSupermemory() {
try {
showToast('loading');
DOMUtils.showToast('loading');
const memoriesTable = document.querySelector('[role="dialog"] table tbody');
if (!memoriesTable) {
showToast('error');
DOMUtils.showToast('error');
return;
}
@ -147,11 +170,10 @@ export default defineContentScript({
console.log('Memories:', memories);
if (memories.length === 0) {
showToast('error');
DOMUtils.showToast('error');
return;
}
const url = window.location.href;
const combinedContent = `ChatGPT Saved Memories:\n\n${memories.map((memory, index) => `${index + 1}. ${memory}`).join('\n\n')}`;
const response = await browser.runtime.sendMessage({
@ -162,148 +184,136 @@ export default defineContentScript({
});
if (response.success) {
showToast('success');
DOMUtils.showToast('success');
} else {
showToast('error');
DOMUtils.showToast('error');
}
} catch (error) {
console.error('Error saving memories to Supermemory:', error);
showToast('error');
DOMUtils.showToast('error');
}
}
function showToast(state: 'loading' | 'success' | 'error') {
if (currentToast) {
currentToast.remove();
function addTwitterImportButton() {
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
return;
}
const toast = document.createElement('div');
toast.id = '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: -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;
`;
if (!document.getElementById('supermemory-toast-styles')) {
const style = document.createElement('style');
style.id = 'supermemory-toast-styles';
style.textContent = `
@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;
}
}
`;
document.head.appendChild(style);
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
return;
}
const icon = document.createElement('div');
icon.style.cssText = `
width: 20px;
height: 20px;
flex-shrink: 0;
`;
const button = createTwitterImportButton(() => {
showTwitterImportUI();
});
document.body.appendChild(button);
}
const text = document.createElement('span');
text.style.cssText = `
font-weight: 500;
`;
function showTwitterImportUI() {
if (twitterImportUI) {
twitterImportUI.remove();
}
if (state === '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';
if (!document.getElementById('supermemory-spinner-styles')) {
const spinStyle = document.createElement('style');
spinStyle.id = 'supermemory-spinner-styles';
spinStyle.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
isTwitterImportOpen = true;
// Check if user is authenticated
browser.storage.local.get(['bearerToken'], ({ bearerToken }) => {
const isAuthenticated = !!bearerToken;
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: any) {
if (!isTwitterImportOpen || !twitterImportUI) return;
const statusDiv = twitterImportUI.querySelector('#twitter-import-status');
const button = twitterImportUI.querySelector('#twitter-import-button');
if (message.type === '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>
`;
document.head.appendChild(spinStyle);
}
text.textContent = 'Adding to Memory...';
} else if (state === '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';
} else if (state === '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';
if (button) {
(button as HTMLButtonElement).disabled = true;
(button as HTMLButtonElement).textContent = 'Importing...';
}
}
toast.appendChild(icon);
toast.appendChild(text);
document.body.appendChild(toast);
currentToast = toast;
if (state === 'success' || state === 'error') {
if (message.type === '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(() => {
if (currentToast === toast) {
toast.style.animation = 'fadeOut 0.3s ease-out';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
if (currentToast === toast) {
currentToast = null;
}
}, 300);
}
hideTwitterImportUI();
}, 3000);
}
}
// 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 && previousElement.id && 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) &&

View file

@ -1,5 +1,5 @@
{
"name": "supermemory-browser-extension",
"name": "Supermemory",
"description": "Supermemory Browser Extension",
"private": true,
"version": "0.0.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,80 @@
/**
* 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: 'bearerToken',
TWITTER_AUTH: 'twitterAuth',
TOKENS_LOGGED: 'tokens_logged',
TWITTER_COOKIE: 'cookie',
TWITTER_CSRF: 'csrf',
TWITTER_AUTH_TOKEN: 'auth',
} as const;
/**
* DOM Element IDs
*/
export const ELEMENT_IDS = {
TWITTER_IMPORT_BUTTON: 'supermemory-twitter-import-button',
TWITTER_IMPORT_STATUS: 'twitter-import-status',
TWITTER_CLOSE_BTN: 'twitter-close-btn',
TWITTER_IMPORT_BTN: 'twitter-import-button',
TWITTER_SIGNIN_BTN: 'twitter-signin-btn',
SUPERMEMORY_TOAST: 'supermemory-toast',
SUPERMEMORY_SAVE_BUTTON: 'supermemory-save-button',
SAVE_TWEET_ELEMENT: 'supermemory-save-tweet-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: 'saveMemory',
SHOW_TOAST: 'showToast',
BATCH_IMPORT_ALL: 'batchImportAll',
IMPORT_UPDATE: 'import-update',
IMPORT_DONE: 'import-done',
} as const;
export const CONTEXT_MENU_IDS = {
SAVE_TO_SUPERMEMORY: 'save-to-supermemory',
} as const;
export const CSS_CLASSES = {
TOAST_STYLES_ID: 'supermemory-toast-styles',
SPINNER_STYLES_ID: 'supermemory-spinner-styles',
} as const;

View file

@ -0,0 +1,85 @@
/**
* Twitter Authentication Module
* Handles token capture and storage for Twitter API access
*/
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: any): boolean {
if (!(details.url.includes('x.com') || details.url.includes('twitter.com'))) {
return false;
}
const authHeader = details.requestHeaders?.find(
(header: any) => header.name.toLowerCase() === 'authorization'
);
const cookieHeader = details.requestHeaders?.find(
(header: any) => header.name.toLowerCase() === 'cookie'
);
const csrfHeader = details.requestHeaders?.find(
(header: any) => header.name.toLowerCase() === 'x-csrf-token'
);
if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) {
browser.storage.session.get(['tokens_logged'], (result) => {
if (!result.tokens_logged) {
console.log('Twitter auth tokens captured successfully');
browser.storage.session.set({ tokens_logged: true });
}
});
browser.storage.session.set({
cookie: cookieHeader.value,
csrf: csrfHeader.value,
auth: 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 browser.storage.session.get(['cookie', 'csrf', 'auth']);
if (!result.cookie || !result.csrf || !result.auth) {
return null;
}
return {
cookie: result.cookie,
csrf: result.csrf,
auth: result.auth
};
}
/**
* 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,209 @@
/**
* Twitter Bookmarks Import Module
* Handles the import process for Twitter bookmarks
*/
import {
BOOKMARKS_URL,
getAllTweets,
extractNextCursor,
tweetToMarkdown,
buildRequestVariables,
type TwitterAPIResponse,
type Tweet
} from './twitter-utils';
import { getTwitterTokens, createTwitterAPIHeaders, type TwitterAuthTokens } from './twitter-auth';
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> {
return new Promise<void>((resolve, reject) => {
browser.storage.local.get(['bearerToken'], ({ bearerToken }) => {
if (!bearerToken) {
reject(new Error('No bearer token found'));
return;
}
const backendURL = 'https://api.supermemory.ai';
fetch(`${backendURL}/v3/memories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
containerTags: ['sm_project_twitter_bookmarks'],
content: tweetMd,
metadata: {
sm_source: 'twitter_bookmarks',
tweet_id: tweet.id_str,
author: tweet.user.screen_name,
created_at: tweet.created_at,
likes: tweet.favorite_count,
retweets: tweet.retweet_count || 0,
},
}),
})
.then(async (response) => {
if (!response.ok) {
if (response.status === 409) {
resolve(); // Skip if already exists
} else {
reject(new Error(`Failed to save tweet: ${response.status}`));
}
} else {
resolve();
}
})
.catch(reject);
});
});
}
/**
* 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 {
// 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);
totalImported++;
await this.config.onProgress(`Imported ${totalImported} 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, totalImported);
} else {
await this.config.onComplete(totalImported);
}
} catch (error) {
console.error('Batch import error:', error);
await this.config.onError(error as Error);
}
}
}

View file

@ -0,0 +1,289 @@
// Twitter API data structures and transformation utilities
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<any>;
};
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: any;
}>;
}>;
};
};
};
}
// 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: any): Tweet | null {
try {
const tweet = input.content?.itemContent?.tweet_results?.result;
if (!tweet || !tweet.legacy) {
return null;
}
// Handle media entities
const media = tweet.legacy.entities?.media || [];
const photos = media
.filter((m: any) => m.type === 'photo')
.map((m: any) => ({
url: m.media_url_https,
width: m.sizes?.large?.w || 0,
height: m.sizes?.large?.h || 0,
}));
const videos = media
.filter((m: any) => m.type === 'video')
.map((m: any) => ({
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: any[]): string | null {
try {
for (const instruction of instructions) {
if (instruction.type === 'TimelineAddEntries' && instruction.entries) {
for (const entry of instruction.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: number = 100) {
const variables = {
count,
includePromotedContent: false,
};
if (cursor) {
(variables as any).cursor = cursor;
}
return variables;
}

View file

@ -0,0 +1,131 @@
/**
* 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?: any;
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]: any;
};
}
/**
* 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;
}
/**
* 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 = any> {
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: string = 'Authentication required') {
super(message, 'AUTH_ERROR');
this.name = 'AuthenticationError';
}
}

View file

@ -0,0 +1,351 @@
/**
* UI Components Module
* Reusable UI components for the browser extension
*/
import { ELEMENT_IDS, UI_CONFIG, API_ENDPOINTS } 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: -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;
`;
// Add keyframe animations if not already present
if (!document.getElementById('supermemory-toast-styles')) {
const style = document.createElement('style');
style.id = 'supermemory-toast-styles';
style.textContent = `
@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;
bottom: 20px;
right: 20px;
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('/light-mode-icon.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: -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. Make sure you're logged into Twitter/X first.
</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;
`;
// Check body background color to determine which icon to use
const bodyStyle = window.getComputedStyle(document.body);
const backgroundColor = bodyStyle.backgroundColor;
const isLightMode = backgroundColor === 'rgb(255, 255, 255)';
const iconFileName = isLightMode ? '/light-mode-icon.png' : '/dark-mode-icon.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;
}
/**
* 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);
},
/**
* 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

@ -4,15 +4,29 @@ import { defineConfig } from 'wxt';
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
permissions: ['contextMenus', 'storage', 'scripting', 'activeTab'],
permissions: [
'contextMenus',
'storage',
'scripting',
'activeTab',
'webRequest',
'tabs'
],
host_permissions: [
'*://x.com/*',
'*://twitter.com/*',
'*://supermemory.ai/*',
'*://api.supermemory.ai/*'
],
web_accessible_resources: [
{
resources: ['icon-16.png'],
resources: ['icon-16.png', 'light-mode-icon.png', 'dark-mode-icon.png'],
matches: ['<all_urls>']
}
],
},
webExt: {
disabled: true,
chromiumArgs: ['--user-data-dir=./.wxt/chrome-data'],
},
});

View file

@ -1,5 +1,5 @@
{
"name": "supermemory",
"name": "supermemory-app",
"private": true,
"scripts": {
"build": "turbo run build",