aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorDhravya <[email protected]>2024-07-01 20:12:56 -0500
committerDhravya <[email protected]>2024-07-01 20:12:56 -0500
commit2c6a96e96f49439853f68ff18d1502cac04d618c (patch)
treec4b541c4ba21bf1b404722d714d5ab32a2644d90 /apps
parentfix link (diff)
downloadsupermemory-2c6a96e96f49439853f68ff18d1502cac04d618c.tar.xz
supermemory-2c6a96e96f49439853f68ff18d1502cac04d618c.zip
spaces function
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/(dash)/(memories)/content.tsx (renamed from apps/web/app/(dash)/memories/content.tsx)176
-rw-r--r--apps/web/app/(dash)/(memories)/memories/page.tsx (renamed from apps/web/app/(dash)/memories/page.tsx)2
-rw-r--r--apps/web/app/(dash)/(memories)/space/[spaceid]/page.tsx17
-rw-r--r--apps/web/app/(dash)/memories/render-tweet.tsx33
-rw-r--r--apps/web/app/actions/doers.ts127
-rw-r--r--apps/web/app/actions/fetchers.ts38
-rw-r--r--apps/web/app/api/store/route.ts6
-rw-r--r--apps/web/components/twitter/icons/icons.module.css9
-rw-r--r--apps/web/components/twitter/icons/index.ts3
-rw-r--r--apps/web/components/twitter/icons/verified-business.tsx53
-rw-r--r--apps/web/components/twitter/icons/verified-government.tsx18
-rw-r--r--apps/web/components/twitter/icons/verified.tsx14
-rw-r--r--apps/web/components/twitter/render-tweet.tsx117
-rw-r--r--apps/web/components/twitter/tweet-header.module.css96
-rw-r--r--apps/web/components/twitter/verified-badge.module.css10
-rw-r--r--apps/web/components/twitter/verified-badge.tsx34
16 files changed, 682 insertions, 71 deletions
diff --git a/apps/web/app/(dash)/memories/content.tsx b/apps/web/app/(dash)/(memories)/content.tsx
index f34e9523..26aed6a5 100644
--- a/apps/web/app/(dash)/memories/content.tsx
+++ b/apps/web/app/(dash)/(memories)/content.tsx
@@ -3,18 +3,44 @@
import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers";
import { Content, StoredSpace } from "@/server/db/schema";
import { MemoriesIcon, NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons";
-import { NotebookIcon, PaperclipIcon } from "lucide-react";
+import {
+ ArrowLeftIcon,
+ MenuIcon,
+ MoveIcon,
+ NotebookIcon,
+ PaperclipIcon,
+ TrashIcon,
+} 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";
+import { MyTweet } from "../../../components/twitter/render-tweet";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@repo/ui/shadcn/dropdown-menu";
+import { Button } from "@repo/ui/shadcn/button";
+import { deleteItem, moveItem } from "@/app/actions/doers";
+import { toast } from "sonner";
export function MemoriesPage({
memoriesAndSpaces,
+ title = "Your Memories",
+ currentSpace,
}: {
memoriesAndSpaces: { memories: Content[]; spaces: StoredSpace[] };
+ title?: string;
+ currentSpace?: StoredSpace;
}) {
const [filter, setFilter] = useState("All");
@@ -69,9 +95,24 @@ export function MemoriesPage({
key={`${memoriesAndSpaces.memories.length + memoriesAndSpaces.spaces.length}`}
className="px-2 md:px-32 py-36 h-full flex mx-auto w-full flex-col gap-6"
>
- <h2 className="text-white w-full font-medium text-3xl text-left font-semibold">
- My Memories
+ {currentSpace && (
+ <Link href={"/memories"} className="flex gap-2 items-center">
+ <ArrowLeftIcon className="w-3 h-3" /> Back to all memories
+ </Link>
+ )}
+
+ <h2 className="text-white w-full text-3xl text-left font-semibold">
+ {title}
</h2>
+ {currentSpace && (
+ <div className="flex gap-4 items-center">
+ Space
+ <div className="flex items-center gap-2 bg-secondary p-2 rounded-xl">
+ <Image src={MemoriesIcon} alt="Spaces icon" className="w-3 h-3" />
+ <span className="text-[#fff]">{currentSpace.name}</span>
+ </div>
+ </div>
+ )}
<Filters setFilter={setFilter} filter={filter} />
@@ -99,6 +140,8 @@ export function MemoriesPage({
"/placeholder-image.svg" // TODO: add this placeholder
}
description={(item.data as Content).description ?? ""}
+ spaces={memoriesAndSpaces.spaces}
+ id={(item.data as Content).id}
/>
);
}
@@ -108,6 +151,7 @@ export function MemoriesPage({
<TabComponent
title={(item.data as StoredSpace).name}
description={`${(item.data as StoredSpace).numItems} memories`}
+ id={(item.data as StoredSpace).id}
/>
);
}
@@ -122,19 +166,24 @@ export function MemoriesPage({
function TabComponent({
title,
description,
+ id,
}: {
title: string;
description: string;
+ id: number;
}) {
return (
- <div className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4 -z-10">
+ <Link
+ href={`/space/${id}`}
+ className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4"
+ >
<div className="flex items-center gap-2 text-xs">
<Image alt="Spaces icon" src={MemoriesIcon} className="size-3" /> Space
</div>
<div className="flex items-center">
<div>
<div className="h-12 w-12 flex justify-center items-center rounded-md">
- {title.slice(0, 2).toUpperCase()}
+ {title.slice(0, 2).toUpperCase()} {id}
</div>
</div>
<div className="grow px-4">
@@ -145,7 +194,7 @@ function TabComponent({
<Image src={NextIcon} alt="Search icon" />
</div>
</div>
- </div>
+ </Link>
);
}
@@ -156,6 +205,8 @@ function LinkComponent({
url,
image,
description,
+ spaces,
+ id,
}: {
type: string;
content: string;
@@ -163,34 +214,95 @@ function LinkComponent({
url: string;
image?: string;
description: string;
+ spaces: StoredSpace[];
+ id: number;
}) {
return (
- <Link
- href={url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
- className={`bg-secondary border-2 border-border rounded-xl ${type === "tweet" ? "" : "p-4"} hover:scale-105 transition duration-200`}
+ <div
+ className={`bg-secondary group relative border-2 border-border rounded-xl ${type === "tweet" ? "" : "p-4"} hover:scale-105 transition duration-200`}
>
- {type === "page" ? (
- <>
- <div className="flex items-center gap-2 text-xs">
- <PaperclipIcon className="w-3 h-3" /> Page
- </div>
- <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
- <div>
- {url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
- </div>
- </>
- ) : type === "note" ? (
- <>
- <div className="flex items-center gap-2 text-xs">
- <NotebookIcon className="w-3 h-3" /> Note
- </div>
- <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
- <div className="line-clamp-3 mt-2">{content.replace(title, "")}</div>
- </>
- ) : type === "tweet" ? (
- <MyTweet tweet={JSON.parse(getRawTweet(content) ?? "{}")} />
- ) : null}
- </Link>
+ <Link
+ href={url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
+ >
+ {type === "page" ? (
+ <>
+ <div className="flex items-center gap-2 text-xs">
+ <PaperclipIcon className="w-3 h-3" /> Page
+ </div>
+ <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
+ <div>
+ {url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
+ </div>
+ </>
+ ) : type === "note" ? (
+ <>
+ <div className="flex items-center gap-2 text-xs">
+ <NotebookIcon className="w-3 h-3" /> Note
+ </div>
+ <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
+ <div className="line-clamp-3 mt-2">
+ {content.replace(title, "")}
+ </div>
+ </>
+ ) : type === "tweet" ? (
+ <MyTweet tweet={JSON.parse(getRawTweet(content) ?? "{}")} />
+ ) : null}
+ </Link>
+ <DropdownMenu modal={false}>
+ <DropdownMenuTrigger className="top-5 right-5 absolute opacity-0 group-focus:opacity-100 group-hover:opacity-100 transition duration-200">
+ <MenuIcon />
+ </DropdownMenuTrigger>
+ <DropdownMenuContent>
+ {spaces.length > 0 && (
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>
+ <MoveIcon className="mr-2 h-4 w-4" />
+ <span>Add to space</span>
+ </DropdownMenuSubTrigger>
+ <DropdownMenuPortal>
+ <DropdownMenuSubContent>
+ {spaces.map((space) => (
+ <DropdownMenuItem>
+ <button
+ className="w-full h-full"
+ onClick={async () => {
+ toast.info("Adding to space...");
+
+ console.log(id, space.id);
+ const response = await moveItem(id, [space.id]);
+
+ if (response.success) {
+ toast.success("Moved to space");
+ console.log("Moved to space");
+ } else {
+ toast.error("Failed to move to space");
+ console.error("Failed to move to space");
+ }
+ }}
+ >
+ {space.name}
+ </button>
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuSubContent>
+ </DropdownMenuPortal>
+ </DropdownMenuSub>
+ )}
+ <DropdownMenuItem asChild>
+ <Button
+ onClick={async () => {
+ await deleteItem(id);
+ }}
+ variant="destructive"
+ className="w-full"
+ >
+ <TrashIcon className="mr-2 h-4 w-4" />
+ Delete
+ </Button>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
);
}
diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/(memories)/memories/page.tsx
index 1856161f..d1aa999a 100644
--- a/apps/web/app/(dash)/memories/page.tsx
+++ b/apps/web/app/(dash)/(memories)/memories/page.tsx
@@ -1,6 +1,6 @@
import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers";
import { redirect } from "next/navigation";
-import MemoriesPage from "./content";
+import MemoriesPage from "../content";
async function Page() {
const { success, data } = await getAllUserMemoriesAndSpaces();
diff --git a/apps/web/app/(dash)/(memories)/space/[spaceid]/page.tsx b/apps/web/app/(dash)/(memories)/space/[spaceid]/page.tsx
new file mode 100644
index 00000000..723fb29e
--- /dev/null
+++ b/apps/web/app/(dash)/(memories)/space/[spaceid]/page.tsx
@@ -0,0 +1,17 @@
+import { getMemoriesInsideSpace } from "@/app/actions/fetchers";
+import { redirect } from "next/navigation";
+import MemoriesPage from "../../content";
+
+async function Page({ params: { spaceid } }: { params: { spaceid: number } }) {
+ const { success, data } = await getMemoriesInsideSpace(spaceid);
+ if (!success ?? !data) return redirect("/home");
+ return (
+ <MemoriesPage
+ memoriesAndSpaces={{ memories: data.memories, spaces: [] }}
+ title={data.spaces[0]?.name}
+ currentSpace={data.spaces[0]}
+ />
+ );
+}
+
+export default Page;
diff --git a/apps/web/app/(dash)/memories/render-tweet.tsx b/apps/web/app/(dash)/memories/render-tweet.tsx
deleted file mode 100644
index 3e1e3746..00000000
--- a/apps/web/app/(dash)/memories/render-tweet.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-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 (
- <TweetContainer className="bg-transparent !m-0 !p-0 !z-0">
- <TweetHeader tweet={tweet} components={components} />
- {tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
- <TweetBody tweet={tweet} />
- {tweet.mediaDetails?.length ? (
- <TweetMedia tweet={tweet} components={components} />
- ) : null}
- {tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
- <TweetInfo tweet={tweet} />
- </TweetContainer>
- );
-};
diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts
index 035b85ec..90103092 100644
--- a/apps/web/app/actions/doers.ts
+++ b/apps/web/app/actions/doers.ts
@@ -191,7 +191,8 @@ export const createMemory = async (input: {
storeToSpaces = [];
}
- console.log(storeToSpaces);
+ console.log("SAVING URL: ", metadata.baseUrl);
+
const vectorSaveResponse = await fetch(
`${process.env.BACKEND_BASE_URL}/api/add`,
{
@@ -298,10 +299,12 @@ export const createMemory = async (input: {
.all();
await Promise.all(
- spaceData.map(async (space) => {
+ spaceData.map(async (s) => {
await db
.insert(contentToSpace)
- .values({ contentId: contentId, spaceId: space.id });
+ .values({ contentId: contentId, spaceId: s.id });
+
+ db.update(space).set({ numItems: s.numItems + 1 });
}),
);
}
@@ -437,6 +440,124 @@ export const linkTelegramToUser = async (
};
};
+export const deleteItem = async (id: number) => {
+ const data = await auth();
+
+ if (!data || !data.user || !data.user.id) {
+ redirect("/signin");
+ return { error: "Not authenticated", success: false };
+ }
+
+ try {
+ const deletedItem = await db
+ .delete(storedContent)
+ .where(eq(storedContent.id, id))
+ .returning();
+
+ if (!deletedItem) {
+ return {
+ success: false,
+ error: "Failed to delete item",
+ };
+ }
+
+ const actualUrl = deletedItem[0]?.url.split("#supermemory-user-")[0];
+
+ console.log(
+ "ACTUAL URL BADBAL;KFJDLKASJFLKDSJFLKDSJFKD LSFJSLKDJF :",
+ actualUrl,
+ );
+
+ await fetch(`${process.env.BACKEND_BASE_URL}/api/delete`, {
+ method: "POST",
+ body: JSON.stringify({
+ websiteUrl: actualUrl,
+ user: data.user.id,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY,
+ },
+ });
+
+ revalidatePath("/memories");
+
+ return {
+ success: true,
+ message: "in-sync",
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error,
+ message: "An error occured while saving your canvas",
+ };
+ }
+};
+
+// TODO: also move in vectorize
+export const moveItem = async (
+ id: number,
+ spaces: number[],
+ fromSpace?: number | undefined,
+): ServerActionReturnType<boolean> => {
+ const data = await auth();
+
+ if (!data || !data.user || !data.user.id) {
+ redirect("/signin");
+ return { error: "Not authenticated", success: false };
+ }
+
+ try {
+ if (fromSpace) {
+ await db
+ .delete(contentToSpace)
+ .where(
+ and(
+ eq(contentToSpace.contentId, id),
+ eq(contentToSpace.spaceId, fromSpace),
+ ),
+ );
+ }
+
+ const addedItem = await db
+ .insert(contentToSpace)
+ .values(spaces.map((spaceId) => ({ contentId: id, spaceId })))
+ .returning();
+
+ if (!(addedItem.length > 0)) {
+ return {
+ success: false,
+ error: "Failed to move item",
+ };
+ }
+
+ await db
+ .update(space)
+ .set({ numItems: sql<number>`numItems + 1` })
+ .where(eq(space.id, addedItem[0]?.spaceId!));
+
+ if (!addedItem) {
+ return {
+ success: false,
+ error: "Failed to move item",
+ };
+ }
+
+ revalidatePath("/memories");
+
+ return {
+ success: true,
+ data: true,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: (error as Error).message,
+ };
+ }
+};
+
export const createCanvas = async () => {
const data = await auth();
diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts
index a4cc9e95..c33f90d1 100644
--- a/apps/web/app/actions/fetchers.ts
+++ b/apps/web/app/actions/fetchers.ts
@@ -9,6 +9,7 @@ import {
chatThreads,
Content,
contentToSpace,
+ space,
storedContent,
StoredSpace,
users,
@@ -75,6 +76,43 @@ export const getAllMemories = async (
return { success: true, data: contentNotInAnySpace };
};
+export const getMemoriesInsideSpace = async (
+ spaceId: number,
+): ServerActionReturnType<{ memories: Content[]; spaces: StoredSpace[] }> => {
+ const data = await auth();
+
+ if (!data || !data.user) {
+ redirect("/signin");
+ return { error: "Not authenticated", success: false };
+ }
+
+ const memories = await db
+ .select()
+ .from(storedContent)
+ .where(
+ inArray(
+ storedContent.id,
+ db
+ .select({ contentId: contentToSpace.contentId })
+ .from(contentToSpace)
+ .where(eq(contentToSpace.spaceId, spaceId)),
+ ),
+ )
+ .execute();
+
+ const queriedSpace = await db.query.space.findFirst({
+ where: and(eq(users, data.user.id), eq(space.id, spaceId)),
+ });
+
+ return {
+ success: true,
+ data: {
+ memories: memories,
+ spaces: queriedSpace ? [queriedSpace] : [],
+ },
+ };
+};
+
export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{
spaces: StoredSpace[];
memories: Content[];
diff --git a/apps/web/app/api/store/route.ts b/apps/web/app/api/store/route.ts
index a67787c4..d9f99277 100644
--- a/apps/web/app/api/store/route.ts
+++ b/apps/web/app/api/store/route.ts
@@ -128,10 +128,12 @@ const createMemoryFromAPI = async (input: {
.all();
await Promise.all(
- spaceData.map(async (space) => {
+ spaceData.map(async (s) => {
await db
.insert(contentToSpace)
- .values({ contentId: contentId, spaceId: space.id });
+ .values({ contentId: contentId, spaceId: s.id });
+
+ await db.update(space).set({ numItems: s.numItems + 1 });
}),
);
}
diff --git a/apps/web/components/twitter/icons/icons.module.css b/apps/web/components/twitter/icons/icons.module.css
new file mode 100644
index 00000000..aca493e6
--- /dev/null
+++ b/apps/web/components/twitter/icons/icons.module.css
@@ -0,0 +1,9 @@
+.verified {
+ margin-left: 0.125rem;
+ max-width: 20px;
+ max-height: 20px;
+ height: 1.25em;
+ fill: currentColor;
+ user-select: none;
+ vertical-align: text-bottom;
+}
diff --git a/apps/web/components/twitter/icons/index.ts b/apps/web/components/twitter/icons/index.ts
new file mode 100644
index 00000000..f90ec616
--- /dev/null
+++ b/apps/web/components/twitter/icons/index.ts
@@ -0,0 +1,3 @@
+export * from "./verified";
+export * from "./verified-business";
+export * from "./verified-government";
diff --git a/apps/web/components/twitter/icons/verified-business.tsx b/apps/web/components/twitter/icons/verified-business.tsx
new file mode 100644
index 00000000..06d574bd
--- /dev/null
+++ b/apps/web/components/twitter/icons/verified-business.tsx
@@ -0,0 +1,53 @@
+import s from "./icons.module.css";
+
+export const VerifiedBusiness = () => (
+ <svg
+ viewBox="0 0 22 22"
+ aria-label="Verified account"
+ role="img"
+ className={s.verified}
+ >
+ <g>
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="0-a"
+ x1="4.411"
+ x2="18.083"
+ y1="2.495"
+ y2="21.508"
+ >
+ <stop offset="0" stopColor="#f4e72a"></stop>
+ <stop offset=".539" stopColor="#cd8105"></stop>
+ <stop offset=".68" stopColor="#cb7b00"></stop>
+ <stop offset="1" stopColor="#f4ec26"></stop>
+ <stop offset="1" stopColor="#f4e72a"></stop>
+ </linearGradient>
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="0-b"
+ x1="5.355"
+ x2="16.361"
+ y1="3.395"
+ y2="19.133"
+ >
+ <stop offset="0" stopColor="#f9e87f"></stop>
+ <stop offset=".406" stopColor="#e2b719"></stop>
+ <stop offset=".989" stopColor="#e2b719"></stop>
+ </linearGradient>
+ <g clipRule="evenodd" fillRule="evenodd">
+ <path
+ d="M13.324 3.848L11 1.6 8.676 3.848l-3.201-.453-.559 3.184L2.06 8.095 3.48 11l-1.42 2.904 2.856 1.516.559 3.184 3.201-.452L11 20.4l2.324-2.248 3.201.452.559-3.184 2.856-1.516L18.52 11l1.42-2.905-2.856-1.516-.559-3.184zm-7.09 7.575l3.428 3.428 5.683-6.206-1.347-1.247-4.4 4.795-2.072-2.072z"
+ fill="url(#0-a)"
+ ></path>
+ <path
+ d="M13.101 4.533L11 2.5 8.899 4.533l-2.895-.41-.505 2.88-2.583 1.37L4.2 11l-1.284 2.627 2.583 1.37.505 2.88 2.895-.41L11 19.5l2.101-2.033 2.895.41.505-2.88 2.583-1.37L17.8 11l1.284-2.627-2.583-1.37-.505-2.88zm-6.868 6.89l3.429 3.428 5.683-6.206-1.347-1.247-4.4 4.795-2.072-2.072z"
+ fill="url(#0-b)"
+ ></path>
+ <path
+ d="M6.233 11.423l3.429 3.428 5.65-6.17.038-.033-.005 1.398-5.683 6.206-3.429-3.429-.003-1.405.005.003z"
+ fill="#d18800"
+ ></path>
+ </g>
+ </g>
+ </svg>
+);
diff --git a/apps/web/components/twitter/icons/verified-government.tsx b/apps/web/components/twitter/icons/verified-government.tsx
new file mode 100644
index 00000000..601a6910
--- /dev/null
+++ b/apps/web/components/twitter/icons/verified-government.tsx
@@ -0,0 +1,18 @@
+import s from "./icons.module.css";
+
+export const VerifiedGovernment = () => (
+ <svg
+ viewBox="0 0 22 22"
+ aria-label="Verified account"
+ role="img"
+ className={s.verified}
+ >
+ <g>
+ <path
+ clipRule="evenodd"
+ d="M12.05 2.056c-.568-.608-1.532-.608-2.1 0l-1.393 1.49c-.284.303-.685.47-1.1.455L5.42 3.932c-.832-.028-1.514.654-1.486 1.486l.069 2.039c.014.415-.152.816-.456 1.1l-1.49 1.392c-.608.568-.608 1.533 0 2.101l1.49 1.393c.304.284.47.684.456 1.1l-.07 2.038c-.027.832.655 1.514 1.487 1.486l2.038-.069c.415-.014.816.152 1.1.455l1.392 1.49c.569.609 1.533.609 2.102 0l1.393-1.49c.283-.303.684-.47 1.099-.455l2.038.069c.832.028 1.515-.654 1.486-1.486L18 14.542c-.015-.415.152-.815.455-1.099l1.49-1.393c.608-.568.608-1.533 0-2.101l-1.49-1.393c-.303-.283-.47-.684-.455-1.1l.068-2.038c.029-.832-.654-1.514-1.486-1.486l-2.038.07c-.415.013-.816-.153-1.1-.456zm-5.817 9.367l3.429 3.428 5.683-6.206-1.347-1.247-4.4 4.795-2.072-2.072z"
+ fillRule="evenodd"
+ ></path>
+ </g>
+ </svg>
+);
diff --git a/apps/web/components/twitter/icons/verified.tsx b/apps/web/components/twitter/icons/verified.tsx
new file mode 100644
index 00000000..81c9fc25
--- /dev/null
+++ b/apps/web/components/twitter/icons/verified.tsx
@@ -0,0 +1,14 @@
+import s from "./icons.module.css";
+
+export const Verified = () => (
+ <svg
+ viewBox="0 0 24 24"
+ aria-label="Verified account"
+ role="img"
+ className={s.verified}
+ >
+ <g>
+ <path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.33 2.19c-1.4-.46-2.91-.2-3.92.81s-1.26 2.52-.8 3.91c-1.31.67-2.2 1.91-2.2 3.34s.89 2.67 2.2 3.34c-.46 1.39-.21 2.9.8 3.91s2.52 1.26 3.91.81c.67 1.31 1.91 2.19 3.34 2.19s2.68-.88 3.34-2.19c1.39.45 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z"></path>
+ </g>
+ </svg>
+);
diff --git a/apps/web/components/twitter/render-tweet.tsx b/apps/web/components/twitter/render-tweet.tsx
new file mode 100644
index 00000000..9d6d1a8a
--- /dev/null
+++ b/apps/web/components/twitter/render-tweet.tsx
@@ -0,0 +1,117 @@
+import type { Tweet } from "react-tweet/api";
+import {
+ type TwitterComponents,
+ TweetContainer,
+ TweetInReplyTo,
+ TweetBody,
+ TweetMedia,
+ TweetInfo,
+ QuotedTweet,
+ enrichTweet,
+ EnrichedTweet,
+} from "react-tweet";
+import clsx from "clsx";
+import s from "./tweet-header.module.css";
+import { VerifiedBadge } from "./verified-badge";
+
+type Props = {
+ tweet: Tweet;
+ components?: TwitterComponents;
+};
+
+type AvatarImgProps = {
+ src: string;
+ alt: string;
+ width: number;
+ height: number;
+};
+const AvatarImg = (props: AvatarImgProps) => <img {...props} />;
+
+const TweetHeader = ({
+ tweet,
+ components,
+}: {
+ tweet: EnrichedTweet;
+ components?: TwitterComponents;
+}) => {
+ const Img = components?.AvatarImg ?? AvatarImg;
+ const { user } = tweet;
+
+ return (
+ <div className={s.header}>
+ <a
+ href={tweet.url}
+ className={s.avatar}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <div
+ className={clsx(
+ s.avatarOverflow,
+ user.profile_image_shape === "Square" && s.avatarSquare,
+ )}
+ >
+ <Img
+ src={user.profile_image_url_https}
+ alt={user.name}
+ width={48}
+ height={48}
+ />
+ </div>
+ <div className={s.avatarOverflow}>
+ <div className={s.avatarShadow}></div>
+ </div>
+ </a>
+ <div className={s.author}>
+ <a
+ href={tweet.url}
+ className={s.authorLink}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <div className={s.authorLinkText}>
+ <span title={user.name}>{user.name}</span>
+ </div>
+ <VerifiedBadge user={user} className={s.authorVerified} />
+ </a>
+ <div className={s.authorMeta}>
+ <a
+ href={tweet.url}
+ className={s.username}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <span title={`@${user.screen_name}`}>@{user.screen_name}</span>
+ </a>
+ <div className={s.authorFollow}>
+ <span className={s.separator}>ยท</span>
+ <a
+ href={user.follow_url}
+ className={s.follow}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Follow
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export const MyTweet = ({ tweet: t, components }: Props) => {
+ const tweet = enrichTweet(t);
+ return (
+ <TweetContainer className="bg-transparent !m-0 !p-0 !z-0">
+ <TweetHeader tweet={tweet} components={components} />
+ {tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
+ <TweetBody tweet={tweet} />
+ {tweet.mediaDetails?.length ? (
+ <TweetMedia tweet={tweet} components={components} />
+ ) : null}
+ {tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
+ <TweetInfo tweet={tweet} />
+ </TweetContainer>
+ );
+};
diff --git a/apps/web/components/twitter/tweet-header.module.css b/apps/web/components/twitter/tweet-header.module.css
new file mode 100644
index 00000000..2ce994e2
--- /dev/null
+++ b/apps/web/components/twitter/tweet-header.module.css
@@ -0,0 +1,96 @@
+.header {
+ display: flex;
+ padding-bottom: 0.75rem;
+ line-height: var(--tweet-header-line-height);
+ font-size: var(--tweet-header-font-size);
+ white-space: nowrap;
+ overflow-wrap: break-word;
+ overflow: hidden;
+}
+
+.avatar {
+ position: relative;
+ height: 48px;
+ width: 48px;
+}
+.avatarOverflow {
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ overflow: hidden;
+ border-radius: 9999px;
+}
+.avatarSquare {
+ border-radius: 4px;
+}
+.avatarShadow {
+ height: 100%;
+ width: 100%;
+ transition-property: background-color;
+ transition-duration: 0.2s;
+ box-shadow: rgb(0 0 0 / 3%) 0px 0px 2px inset;
+}
+.avatarShadow:hover {
+ background-color: rgba(26, 26, 26, 0.15);
+}
+
+.author {
+ max-width: calc(100% - 84px);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin: 0 0.5rem;
+}
+.authorLink {
+ text-decoration: none;
+ color: inherit;
+ display: flex;
+ align-items: center;
+}
+.authorLink:hover {
+ text-decoration-line: underline;
+}
+.authorVerified {
+ display: inline-flex;
+}
+.authorLinkText {
+ font-weight: 700;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.authorMeta {
+ display: flex;
+}
+.authorFollow {
+ display: flex;
+}
+.username {
+ color: var(--tweet-font-color-secondary);
+ text-decoration: none;
+ text-overflow: ellipsis;
+}
+.follow {
+ color: var(--tweet-color-blue-secondary);
+ text-decoration: none;
+ font-weight: 700;
+}
+.follow:hover {
+ text-decoration-line: underline;
+}
+.separator {
+ padding: 0 0.25rem;
+}
+
+.brand {
+ margin-inline-start: auto;
+}
+
+.twitterIcon {
+ width: 23.75px;
+ height: 23.75px;
+ color: var(--tweet-twitter-icon-color);
+ fill: currentColor;
+ user-select: none;
+}
diff --git a/apps/web/components/twitter/verified-badge.module.css b/apps/web/components/twitter/verified-badge.module.css
new file mode 100644
index 00000000..c16e77ec
--- /dev/null
+++ b/apps/web/components/twitter/verified-badge.module.css
@@ -0,0 +1,10 @@
+.verifiedOld {
+ color: var(--tweet-verified-old-color);
+}
+.verifiedBlue {
+ color: var(--tweet-verified-blue-color);
+}
+.verifiedGovernment {
+ /* color: var(--tweet-verified-government-color); */
+ color: rgb(130, 154, 171);
+}
diff --git a/apps/web/components/twitter/verified-badge.tsx b/apps/web/components/twitter/verified-badge.tsx
new file mode 100644
index 00000000..daa11852
--- /dev/null
+++ b/apps/web/components/twitter/verified-badge.tsx
@@ -0,0 +1,34 @@
+import clsx from "clsx";
+import { Verified, VerifiedBusiness, VerifiedGovernment } from "./icons/index";
+import s from "./verified-badge.module.css";
+
+type Props = {
+ user: any;
+ className?: string;
+};
+
+export const VerifiedBadge = ({ user, className }: Props) => {
+ const verified = user.verified || user.is_blue_verified || user.verified_type;
+ let icon = <Verified />;
+ let iconClassName: string | null = s.verifiedBlue ?? null;
+
+ if (verified) {
+ if (!user.is_blue_verified) {
+ iconClassName = s.verifiedOld!;
+ }
+ switch (user.verified_type) {
+ case "Government":
+ icon = <VerifiedGovernment />;
+ iconClassName = s.verifiedGovernment!;
+ break;
+ case "Business":
+ icon = <VerifiedBusiness />;
+ iconClassName = null;
+ break;
+ }
+ }
+
+ return verified ? (
+ <div className={clsx(className, iconClassName)}>{icon}</div>
+ ) : null;
+};