From 7676ed57065eb61d54d500e68c7a7faae16e558b Mon Sep 17 00:00:00 2001 From: Dhravya Date: Sun, 30 Jun 2024 14:07:16 -0500 Subject: [PATCH] added things --- apps/extension/background.ts | 306 +++++++- apps/extension/content/ContentApp.tsx | 158 +++- apps/extension/content/content.tsx | 35 +- apps/extension/manifest.json | 8 +- apps/web/app/(dash)/home/homeVariants.ts | 2 +- apps/web/app/(dash)/home/page.tsx | 7 +- apps/web/app/(dash)/memories/page.tsx | 90 ++- apps/web/app/(dash)/memories/render-tweet.tsx | 33 + apps/web/app/(dash)/menu.tsx | 21 +- apps/web/app/actions/doers.ts | 90 ++- apps/web/app/actions/fetchers.ts | 14 + apps/web/app/api/store/route.ts | 202 +++++ .../web/migrations/0001_index_telegram_id.sql | 4 +- ...0_classy_speed_demon.sql => 000_setup.sql} | 0 apps/web/migrations/meta/0001_snapshot.json | 698 ++++++++++++++++++ apps/web/migrations/meta/_journal.json | 7 + apps/web/server/db/schema.ts | 2 +- apps/web/tsconfig.json | 3 +- package.json | 5 +- packages/shared-types/index.ts | 13 + packages/shared-types/package.json | 4 +- packages/shared-types/tsconfig.json | 6 +- packages/typescript-config/react-library.json | 2 +- packages/ui/shadcn/combobox.tsx | 6 +- 24 files changed, 1624 insertions(+), 92 deletions(-) create mode 100644 apps/web/app/(dash)/memories/render-tweet.tsx create mode 100644 apps/web/app/api/store/route.ts rename apps/web/migrations/{0000_classy_speed_demon.sql => 000_setup.sql} (100%) create mode 100644 apps/web/migrations/meta/0001_snapshot.json diff --git a/apps/extension/background.ts b/apps/extension/background.ts index dabb975c..5128ffed 100644 --- a/apps/extension/background.ts +++ b/apps/extension/background.ts @@ -1 +1,305 @@ -console.log("Hello from the background script!"); +import { Tweet } from "react-tweet/api"; +import { features, transformTweetData } from "./helpers"; + +const tweetToMd = (tweet: Tweet) => { + return `Tweet from @${tweet.user?.name ?? tweet.user?.screen_name ?? "Unknown"} + + ${tweet.text} + Images: ${tweet.photos ? tweet.photos.map((photo) => photo.url).join(", ") : "none"} + Time: ${tweet.created_at}, Likes: ${tweet.favorite_count}, Retweets: ${tweet.conversation_count} + + ${JSON.stringify(tweet)}`; +}; + +const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(features))}`; + +const BACKEND_URL = "http://localhost:3000"; + +// This is to prevent going over the rate limit +let lastTwitterFetch = 0; + +const batchImportAll = async (cursor = "") => { + chrome.storage.session.get(["cookie", "csrf", "auth"], (result) => { + if (!result.cookie || !result.csrf || !result.auth) { + console.log("cookie, csrf, or auth is missing"); + return; + } + + const myHeaders = new Headers(); + myHeaders.append("Cookie", result.cookie); + myHeaders.append("X-Csrf-token", result.csrf); + myHeaders.append("Authorization", result.auth); + + const requestOptions: RequestInit = { + method: "GET", + headers: myHeaders, + redirect: "follow", + }; + + const variables = { + count: 100, // Using 100 as the count to prevent rate limiting + cursor: cursor, + includePromotedContent: false, + }; + + // Append cursor if present + const urlWithCursor = cursor + ? `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}` + : BOOKMARKS_URL; + + fetch(urlWithCursor, requestOptions) + .then((response) => response.json()) + .then((data) => { + const tweets = getAllTweets(data); + + for (const tweet of tweets) { + console.log(tweet); + + const tweetMd = tweetToMd(tweet); + (async () => { + chrome.storage.local.get(["jwt"], ({ jwt }) => { + if (!jwt) { + console.error("No JWT found"); + return; + } + fetch(`${BACKEND_URL}/api/store`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + pageContent: tweetMd, + url: `https://twitter.com/supermemoryai/status/${tweet.id_str}`, + title: `Tweet by ${tweet.user.name}`, + description: tweet.text.slice(0, 100), + type: "tweet", + }), + }).then((ers) => console.log(ers.status)); + }); + })(); + } + + console.log("tweets", tweets); + + console.log("data", data); + + // Extract next cursor + const instructions = + data.data?.bookmark_timeline_v2?.timeline?.instructions; + const lastInstruction = instructions?.[0].entries.pop(); + + if (lastInstruction?.entryId.startsWith("cursor-bottom-")) { + let nextCursor = lastInstruction?.content?.value; + + if (!nextCursor) { + // Find the nextcursor in the entire instructions array + for (let i = instructions.length - 1; i >= 0; i--) { + if (instructions[i].entryId.startsWith("cursor-bottom-")) { + nextCursor = instructions[i].content.value; + break; + } + } + } + + if (nextCursor) { + batchImportAll(nextCursor); // Recursively call with new cursor + } else { + // No more cursors, run final function + console.log("All bookmarks imported"); + // Run function maybe + } + } else { + // No cursor-bottom-* found, run final function + console.log("All bookmarks imported"); + // Run function maybe + } + }) + .catch((error) => console.error(error)); + }); +}; + +chrome.webRequest.onBeforeSendHeaders.addListener( + (details) => { + if ( + !(details.url.includes("x.com") || details.url.includes("twitter.com")) + ) { + return; + } + const authHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "authorization", + ); + const auth = authHeader ? authHeader.value : ""; + + const cookieHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "cookie", + ); + const cookie = cookieHeader ? cookieHeader.value : ""; + + const csrfHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "x-csrf-token", + ); + const csrf = csrfHeader ? csrfHeader.value : ""; + + if (!auth || !cookie || !csrf) { + console.log("auth, cookie, or csrf is missing"); + return; + } + chrome.storage.session.set({ cookie, csrf, auth }); + chrome.storage.local.get(["twitterBookmarks"], (result) => { + console.log("twitterBookmarks", result.twitterBookmarks); + if (result.twitterBookmarks !== "true") { + console.log("twitterBookmarks is NOT true"); + } else { + if ( + !details.requestHeaders || + details.requestHeaders.length === 0 || + details.requestHeaders === undefined + ) { + return; + } + + // Check cache first + chrome.storage.local.get(["lastFetch", "cachedData"], (result) => { + const now = new Date().getTime(); + if (result.lastFetch && now - result.lastFetch < 30 * 60 * 1000) { + // Cached data is less than 30 minutes old, use it + console.log("Using cached data"); + console.log(result.cachedData); + return; + } + + // No valid cache, proceed to fetch + const authHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "authorization", + ); + const auth = authHeader ? authHeader.value : ""; + + const cookieHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "cookie", + ); + const cookie = cookieHeader ? cookieHeader.value : ""; + + const csrfHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "x-csrf-token", + ); + const csrf = csrfHeader ? csrfHeader.value : ""; + + if (!auth || !cookie || !csrf) { + console.log("auth, cookie, or csrf is missing"); + return; + } + chrome.storage.session.set({ cookie, csrf, auth }); + + const myHeaders = new Headers(); + myHeaders.append("Cookie", cookie); + myHeaders.append("X-Csrf-token", csrf); + myHeaders.append("Authorization", auth); + + const requestOptions: RequestInit = { + method: "GET", + headers: myHeaders, + redirect: "follow", + }; + + const variables = { + count: 200, + includePromotedContent: false, + }; + + // only fetch once in 1 minute + if (now - lastTwitterFetch < 60 * 1000) { + console.log("Waiting for ratelimits"); + return; + } + + fetch( + `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}`, + requestOptions, + ) + .then((response) => response.text()) + .then((result) => { + const tweets = getAllTweets(JSON.parse(result)); + + console.log("tweets", tweets); + // Cache the result along with the current timestamp + chrome.storage.local.set({ + lastFetch: new Date().getTime(), + cachedData: tweets, + }); + + lastTwitterFetch = now; + }) + .catch((error) => console.error(error)); + }); + return; + } + }); + }, + { urls: ["*://x.com/*", "*://twitter.com/*"] }, + ["requestHeaders", "extraHeaders"], +); + +const getAllTweets = (rawJson: any): Tweet[] => { + const entries = + rawJson?.data?.bookmark_timeline_v2?.timeline?.instructions[0]?.entries; + + console.log("Entries: ", entries); + + if (!entries) { + console.error("No entries found"); + return []; + } + + const tweets = entries + .map((entry: any) => transformTweetData(entry)) + .filter((tweet: Tweet | null) => tweet !== null) as Tweet[]; + + console.log(tweets); + + return tweets; +}; + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + console.log(request); + if (request.type === "getJwt") { + chrome.storage.local.get(["jwt"], ({ jwt }) => { + sendResponse({ jwt }); + }); + + return true; + } else if (request.type === "urlSave") { + const content = request.content; + const url = request.url; + const title = request.title; + const description = request.description; + const ogImage = request.ogImage; + const favicon = request.favicon; + console.log(request.content, request.url); + + (async () => { + chrome.storage.local.get(["jwt"], ({ jwt }) => { + if (!jwt) { + console.error("No JWT found"); + return; + } + fetch(`${BACKEND_URL}/api/store`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + pageContent: content, + url, + title, + spaces: request.spaces, + description, + ogImage, + image: favicon, + }), + }).then((ers) => console.log(ers.status)); + }); + })(); + } else if (request.type === "batchImportAll") { + batchImportAll(); + } +}); diff --git a/apps/extension/content/ContentApp.tsx b/apps/extension/content/ContentApp.tsx index ca1782c1..ee1fd6d4 100644 --- a/apps/extension/content/ContentApp.tsx +++ b/apps/extension/content/ContentApp.tsx @@ -1,10 +1,15 @@ import React, { useEffect, useState } from "react"; -import icon from "../public/icon/icon_48.png"; +import { Readability } from "@mozilla/readability"; -export default function ContentApp() { +export default function ContentApp({ token }: { token: string | undefined }) { const [text, setText] = useState(""); const [hover, setHover] = useState(false); + const [loading, setLoading] = useState(false); + + const [isTwitterBookmarksEnabled, setIsTwitterBookmarksEnabled] = + useState(false); + useEffect(() => { const messageListener = (message: any) => { setText(message); @@ -22,18 +27,163 @@ export default function ContentApp() { setHover(false); } }); + + const getUserData = () => { + const NO_JWT = [ + "supermemory.ai", + "beta.supermemory.ai", + "localhost:3000", + ]; + chrome.runtime.sendMessage({ type: "getJwt" }, (response) => { + if (!response.jwt && !NO_JWT.includes(window.location.host)) { + window.location.href = "https://supermemory.ai/signin"; + } + + console.log("jwt", response.jwt); + }); + }; + + getUserData(); + return () => { chrome.runtime.onMessage.removeListener(messageListener); }; }, []); + function sendUrlToAPI(spaces: number[]) { + setLoading(true); + + setTimeout(() => { + setLoading(false); + }, 1500); + + // get the current URL + const url = window.location.href; + + const blacklist: string[] = []; + // check if the URL is blacklisted + if (blacklist.some((blacklisted) => url.includes(blacklisted))) { + console.log("URL is blacklisted"); + return; + } else { + const clone = document.cloneNode(true) as Document; + const article = new Readability(clone).parse(); + + const ogImage = document + .querySelector('meta[property="og:image"]') + ?.getAttribute("content"); + + const favicon = ( + document.querySelector('link[rel="icon"]') as HTMLLinkElement + )?.href; + + console.log("article", article); + chrome.runtime.sendMessage({ + type: "urlSave", + content: article?.textContent, + url, + spaces, + title: article?.title, + description: article?.excerpt, + ogImage: ogImage, + favicon: favicon, + }); + } + } + return (
+ + + + {(window.location.href === "https://twitter.com" || + window.location.href === "https://x.com") && + (isTwitterBookmarksEnabled ? ( + + ) : ( + <> + + + ))}
); } diff --git a/apps/extension/content/content.tsx b/apps/extension/content/content.tsx index df66cab0..bacb79c0 100644 --- a/apps/extension/content/content.tsx +++ b/apps/extension/content/content.tsx @@ -31,7 +31,7 @@ function initial() { // Create a new div element to host the shadow root. // Styles for this div is in `content/content.css` const hostDiv = document.createElement("div"); - hostDiv.id = "extension-host"; + hostDiv.id = "supermemory-extension-host"; document.body.appendChild(hostDiv); // Attach the shadow DOM to the hostDiv and set the mode to @@ -41,11 +41,40 @@ function initial() { // Create a new div element that will be the root container for the React app const rootDiv = document.createElement("div"); - rootDiv.id = "extension-root"; + rootDiv.id = "supermemory-extension-root"; shadowRoot.appendChild(rootDiv); appendTailwindStyleLink(shadowRoot); const root = ReactDOM.createRoot(rootDiv); - root.render(); + + const jwt = chrome.storage.local.get("jwt").then((data) => { + return data.jwt; + }) as Promise; + + jwt.then((token) => root.render()); } + +window.addEventListener("message", (event) => { + if (event.source !== window) { + return; + } + const jwt = event.data.token; + + if (jwt) { + if ( + !( + window.location.hostname === "localhost" || + window.location.hostname === "supermemory.ai" || + window.location.hostname === "beta.supermemory.ai" + ) + ) { + console.log( + "JWT is only allowed to be used on localhost or anycontext.dhr.wtf", + ); + return; + } + + chrome.storage.local.set({ jwt }, () => {}); + } +}); diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index 72e26344..b898cb0b 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "version": "1.0", - "name": "content-shadow-dom-tailwind", - "description": "An extension template using React and TypeScript. This template includes a content script using Tailwind.css. To see it in action, visit https://docs.extensioncreate.com.", + "name": "supermemory", + "description": "An extension for https://supermemory.ai - an AI hub for all your bookmarks.", "background": { "service_worker": "./background.ts" }, @@ -21,5 +21,7 @@ "resources": ["public/*"], "matches": ["http://*/*", "https://*/*"] } - ] + ], + "permissions": ["webRequest", "storage"], + "host_permissions": ["https://x.com/*", "https://twitter.com/*"] } diff --git a/apps/web/app/(dash)/home/homeVariants.ts b/apps/web/app/(dash)/home/homeVariants.ts index ec24e22b..cc533fc4 100644 --- a/apps/web/app/(dash)/home/homeVariants.ts +++ b/apps/web/app/(dash)/home/homeVariants.ts @@ -44,7 +44,7 @@ export const variants = [ }, { type: "highlighted", - content: " digital treasures.", + content: " digital treasure.", }, ], ]; diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index 4f7e4af6..f648923c 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import QueryInput from "./queryinput"; import { homeSearchParamsCache } from "@/lib/searchParams"; -import { getSpaces } from "@/app/actions/fetchers"; +import { getSessionAuthToken, getSpaces } from "@/app/actions/fetchers"; import { useRouter } from "next/navigation"; import { createChatThread, linkTelegramToUser } from "@/app/actions/doers"; import { toast } from "sonner"; @@ -67,6 +67,11 @@ function Page({ }); setShowVariant(Math.floor(Math.random() * variants.length)); + + getSessionAuthToken().then((token) => { + if (typeof window === "undefined") return; + window.postMessage({ token: token.data }, "*"); + }); }, []); return ( diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx index cb3825e7..380a653e 100644 --- a/apps/web/app/(dash)/memories/page.tsx +++ b/apps/web/app/(dash)/memories/page.tsx @@ -1,18 +1,19 @@ "use client"; import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers"; -import { Space } from "@/app/actions/types"; import { Content, StoredSpace } from "@/server/db/schema"; -import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; +import { MemoriesIcon, NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; +import { NotebookIcon, PaperclipIcon } from "lucide-react"; import Image from "next/image"; +import Link from "next/link"; import React, { useEffect, useMemo, useState } from "react"; import Masonry from "react-layout-masonry"; +import { getRawTweet } from "@repo/shared-types/utils"; +import { MyTweet } from "./render-tweet"; function Page() { const [filter, setFilter] = useState("All"); - const [search, setSearch] = useState(""); - const [memoriesAndSpaces, setMemoriesAndSpaces] = useState<{ memories: Content[]; spaces: StoredSpace[]; @@ -55,9 +56,13 @@ function Page() { return ( item.item === "memory" && (item.data as Content).type === "note" ); + if (filter === "Tweet") + return ( + item.item === "memory" && (item.data as Content).type === "tweet" + ); return false; }) - .sort((a, b) => a.date - b.date); + .sort((a, b) => b.date - a.date); }, [memoriesAndSpaces.memories, memoriesAndSpaces.spaces, filter]); useEffect(() => { @@ -69,7 +74,7 @@ function Page() { }, []); return ( -
+

My Memories

@@ -79,7 +84,7 @@ function Page() { {sortedItems.map((item) => { if (item.item === "memory") { @@ -88,12 +93,15 @@ function Page() { type={(item.data as Content).type ?? "note"} content={(item.data as Content).content} title={(item.data as Content).title ?? "Untitled"} - url={(item.data as Content).url} + url={ + (item.data as Content).baseUrl ?? (item.data as Content).url + } image={ (item.data as Content).ogImage ?? (item.data as Content).image ?? "/placeholder-image.svg" // TODO: add this placeholder } + description={(item.data as Content).description ?? ""} /> ); } @@ -123,47 +131,73 @@ function TabComponent({ }) { // TODO: Display the space name and desription which is the number of elemenet in the space return ( -
-
-
- {title.slice(0, 2).toUpperCase()} +
+
+ Spaces icon Space +
+
+
+
+ {title.slice(0, 2).toUpperCase()} +
+
+
+
{title}
+
{description}
+
+
+ Search icon
-
-
-
{title}
-
{description}
-
-
- Search icon
); } -function LinkComponent({ +export function LinkComponent({ type, content, title, url, image, + description, }: { type: string; content: string; title: string; url: string; image?: string; + description: string; }) { // TODO: DISPLAY THE ITEM BASED ON `type` being note or page return ( -
-
{title}
-
{content}
-
{url}
-
+ + {type === "page" ? ( + <> +
+ Page +
+
{title}
+
{url}
+ + ) : type === "note" ? ( + <> +
+ Note +
+
{title}
+
{content.replace(title, "")}
+ + ) : type === "tweet" ? ( + + ) : null} + ); } -const FilterMethods = ["All", "Spaces", "Pages", "Notes"]; +const FilterMethods = ["All", "Spaces", "Pages", "Notes", "Tweet"]; function Filters({ setFilter, filter, @@ -175,12 +209,12 @@ function Filters({
{FilterMethods.map((i) => { return ( -
setFilter(i)} className={`transition px-6 py-2 rounded-xl bg-border ${i === filter ? " text-[#369DFD]" : "text-[#B3BCC5] bg-secondary hover:bg-secondary hover:text-[#76a3cc]"}`} > {i} -
+ ); })}
diff --git a/apps/web/app/(dash)/memories/render-tweet.tsx b/apps/web/app/(dash)/memories/render-tweet.tsx new file mode 100644 index 00000000..3e1e3746 --- /dev/null +++ b/apps/web/app/(dash)/memories/render-tweet.tsx @@ -0,0 +1,33 @@ +import type { Tweet } from "react-tweet/api"; +import { + type TwitterComponents, + TweetContainer, + TweetHeader, + TweetInReplyTo, + TweetBody, + TweetMedia, + TweetInfo, + QuotedTweet, + enrichTweet, +} from "react-tweet"; + +type Props = { + tweet: Tweet; + components?: TwitterComponents; +}; + +export const MyTweet = ({ tweet: t, components }: Props) => { + const tweet = enrichTweet(t); + return ( + + + {tweet.in_reply_to_status_id_str && } + + {tweet.mediaDetails?.length ? ( + + ) : null} + {tweet.quoted_tweet && } + + + ); +}; diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 340c7e16..a8ba4172 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -64,12 +64,6 @@ function Menu() { url: "/memories", disabled: false, }, - { - icon: ExploreIcon, - text: "Explore", - url: "/explore", - disabled: true, - }, { icon: CanvasIcon, text: "Canvas", @@ -86,7 +80,9 @@ function Menu() { return "none"; } - if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) { + if ( + content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/) + ) { return "tweet"; } else if (content.match(/https?:\/\/[\w\.]+/)) { return "page"; @@ -136,8 +132,8 @@ function Menu() { <> {/* Desktop Menu */} -
-
+
+
- +
{ const content = e.get("content")?.toString(); @@ -202,7 +198,7 @@ function Menu() { Resource (URL or content)