mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-26 17:02:32 +00:00
245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
"use client"
|
|
|
|
import { Suspense, useMemo } from "react"
|
|
import type { Tweet } from "react-tweet/api"
|
|
import { TweetBody, enrichTweet, TweetSkeleton } from "react-tweet"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/lib/fonts"
|
|
import { PlayCircle } from "lucide-react"
|
|
|
|
function VerifiedBadge({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 22 22"
|
|
aria-label="Verified account"
|
|
className={cn("size-3", className)}
|
|
>
|
|
<title>Verified</title>
|
|
<g>
|
|
<path
|
|
fill="#1D9BF0"
|
|
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"
|
|
/>
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function XLogo({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
className={cn("size-3 fill-white", className)}
|
|
>
|
|
<title>X</title>
|
|
<g>
|
|
<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" />
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function CustomTweetHeader({
|
|
tweet,
|
|
}: {
|
|
tweet: ReturnType<typeof enrichTweet>
|
|
}) {
|
|
const user = tweet.user
|
|
if (!user) return null
|
|
const isVerified = user.verified || user.is_blue_verified
|
|
|
|
return (
|
|
<div className="flex items-start justify-between pr-0.5 w-full">
|
|
<div className="flex gap-2 items-center">
|
|
<div className="bg-white overflow-hidden rounded-full shrink-0 size-[30px]">
|
|
<img
|
|
src={user.profile_image_url_https}
|
|
alt={user.name}
|
|
className="size-full object-cover"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col items-start">
|
|
<div className="flex gap-0.5 items-center">
|
|
<p
|
|
className={cn(
|
|
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[13px] truncate tracking-[-0.12px]",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{user.name}
|
|
</p>
|
|
{isVerified && <VerifiedBadge />}
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"font-medium leading-tight overflow-hidden text-[#737373] text-[13px] truncate tracking-[-0.12px]",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
@{user.screen_name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1.5 items-center">
|
|
<XLogo />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomTweetMedia({
|
|
tweet,
|
|
}: {
|
|
tweet: ReturnType<typeof enrichTweet>
|
|
}) {
|
|
const media = tweet.mediaDetails?.[0]
|
|
if (!media) return null
|
|
|
|
const isVideo = media.type === "video" || media.type === "animated_gif"
|
|
const previewUrl = media.media_url_https
|
|
|
|
return (
|
|
<div className="relative w-full overflow-hidden rounded-[6px] border border-[rgba(47,50,54,0.2)]">
|
|
<div className="relative w-full aspect-video">
|
|
<img
|
|
src={previewUrl}
|
|
alt="Tweet media"
|
|
className="size-full object-cover"
|
|
/>
|
|
{isVideo && (
|
|
<div className="absolute inset-0 bg-[rgba(4,5,5,0.8)] flex items-center justify-center">
|
|
<PlayCircle className="size-8 text-white" strokeWidth={1.5} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function isTweetLike(value: unknown): value is Tweet {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const candidate = value as Record<string, unknown>
|
|
const user = candidate.user
|
|
if (
|
|
typeof user !== "object" ||
|
|
user === null ||
|
|
Array.isArray(user) ||
|
|
typeof (user as { screen_name?: unknown }).screen_name !== "string"
|
|
) {
|
|
return false
|
|
}
|
|
if (typeof candidate.text !== "string") return false
|
|
if (!Array.isArray(candidate.display_text_range)) return false
|
|
const entities = candidate.entities
|
|
if (
|
|
typeof entities !== "object" ||
|
|
entities === null ||
|
|
Array.isArray(entities)
|
|
) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function ensureArray<T>(value: unknown): T[] {
|
|
return Array.isArray(value) ? (value as T[]) : []
|
|
}
|
|
|
|
// enrichTweet iterates entities.hashtags, .user_mentions, .urls and .symbols
|
|
// with `for...of`, so any of them being absent throws "not iterable". A real
|
|
// syndication payload always carries the four arrays (often empty), so default
|
|
// them before the tweet reaches enrichTweet. `media` is intentionally left
|
|
// untouched: enrichTweet only reads it when truthy and indexes media[0], so an
|
|
// empty array would crash — the enrich-time guard below covers that case.
|
|
function normalizeTweet(tweet: Tweet): Tweet {
|
|
const entities = tweet.entities as unknown as Record<string, unknown>
|
|
return {
|
|
...tweet,
|
|
entities: {
|
|
...tweet.entities,
|
|
hashtags: ensureArray(entities.hashtags),
|
|
user_mentions: ensureArray(entities.user_mentions),
|
|
urls: ensureArray(entities.urls),
|
|
symbols: ensureArray(entities.symbols),
|
|
},
|
|
}
|
|
}
|
|
|
|
function parseTweetData(data: Tweet | string): Tweet | null {
|
|
if (!data) return null
|
|
let value: unknown = data
|
|
if (typeof data === "string") {
|
|
try {
|
|
value = JSON.parse(data)
|
|
} catch (error) {
|
|
console.warn("TweetPreview: failed to parse tweet data", error)
|
|
return null
|
|
}
|
|
}
|
|
if (!isTweetLike(value)) {
|
|
console.warn("TweetPreview: parsed value did not match Tweet shape")
|
|
return null
|
|
}
|
|
return normalizeTweet(value)
|
|
}
|
|
|
|
function TweetPreviewFallback({ noBgColor }: { noBgColor?: boolean }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-full min-w-0 text-center text-[13px] text-[#737373]",
|
|
noBgColor ? "bg-transparent py-4" : "bg-black rounded-[18px] p-4",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
Tweet preview unavailable
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function TweetPreview({
|
|
data,
|
|
noBgColor,
|
|
}: {
|
|
data: Tweet | string
|
|
noBgColor?: boolean
|
|
}) {
|
|
const tweet = useMemo(() => {
|
|
const parsed = parseTweetData(data)
|
|
if (!parsed) return null
|
|
try {
|
|
return enrichTweet(parsed)
|
|
} catch (error) {
|
|
// enrichTweet still walks quoted tweets, media entities and per-entity
|
|
// indices, so a partially-malformed payload can throw here even after
|
|
// the shape check. Degrade to the fallback instead of crashing render.
|
|
console.warn("TweetPreview: failed to enrich tweet data", error)
|
|
return null
|
|
}
|
|
}, [data])
|
|
|
|
if (!tweet) return <TweetPreviewFallback noBgColor={noBgColor} />
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-full min-w-0",
|
|
noBgColor ? "bg-transparent" : "bg-black rounded-[18px] p-3",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
<Suspense fallback={<TweetSkeleton />}>
|
|
<div className="flex flex-col gap-3 w-full">
|
|
<CustomTweetHeader tweet={tweet} />
|
|
<div className="sm-tweet-theme w-full min-w-0">
|
|
<TweetBody tweet={tweet} />
|
|
</div>
|
|
<CustomTweetMedia tweet={tweet} />
|
|
</div>
|
|
</Suspense>
|
|
</div>
|
|
)
|
|
}
|