TweetCard.tsx
1 import { cn } from "@/lib/utils"; 2 import { Suspense } from "react"; 3 import { 4 enrichTweet, 5 type EnrichedTweet, 6 type TweetProps, 7 type TwitterComponents, 8 } from "react-tweet"; 9 import { getTweet, type Tweet } from "react-tweet/api"; 10 11 interface TwitterIconProps { 12 className?: string; 13 [key: string]: any; 14 } 15 const Twitter = ({ className, ...props }: TwitterIconProps) => ( 16 <svg 17 stroke="currentColor" 18 fill="currentColor" 19 strokeWidth="0" 20 viewBox="0 0 24 24" 21 height="1em" 22 width="1em" 23 xmlns="http://www.w3.org/2000/svg" 24 className={className} 25 {...props} 26 > 27 <g> 28 <path fill="none" d="M0 0h24v24H0z"></path> 29 <path d="M22.162 5.656a8.384 8.384 0 0 1-2.402.658A4.196 4.196 0 0 0 21.6 4c-.82.488-1.719.83-2.656 1.015a4.182 4.182 0 0 0-7.126 3.814 11.874 11.874 0 0 1-8.62-4.37 4.168 4.168 0 0 0-.566 2.103c0 1.45.738 2.731 1.86 3.481a4.168 4.168 0 0 1-1.894-.523v.052a4.185 4.185 0 0 0 3.355 4.101 4.21 4.21 0 0 1-1.89.072A4.185 4.185 0 0 0 7.97 16.65a8.394 8.394 0 0 1-6.191 1.732 11.83 11.83 0 0 0 6.41 1.88c7.693 0 11.9-6.373 11.9-11.9 0-.18-.005-.362-.013-.54a8.496 8.496 0 0 0 2.087-2.165z"></path> 30 </g> 31 </svg> 32 ); 33 34 const Verified = ({ className, ...props }: TwitterIconProps) => ( 35 <svg 36 aria-label="Verified Account" 37 viewBox="0 0 24 24" 38 className={className} 39 {...props} 40 > 41 <g fill="currentColor"> 42 <path d="M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .494.083.964.237 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z" /> 43 </g> 44 </svg> 45 ); 46 47 export const truncate = (str: string | null, length: number) => { 48 if (!str || str.length <= length) return str; 49 return `${str.slice(0, length - 3)}...`; 50 }; 51 52 const Skeleton = ({ 53 className, 54 ...props 55 }: React.HTMLAttributes<HTMLDivElement>) => { 56 return ( 57 <div 58 className={cn("animate-pulse rounded-md bg-primary/10", className)} 59 {...props} 60 /> 61 ); 62 }; 63 64 export const TweetSkeleton = ({ 65 className, 66 ...props 67 }: { 68 className?: string; 69 [key: string]: any; 70 }) => ( 71 <div 72 className={cn( 73 "flex h-full max-h-max w-full min-w-[18rem] flex-col gap-2 rounded-lg border p-4", 74 className, 75 )} 76 {...props} 77 > 78 <div className="flex flex-row gap-2"> 79 <Skeleton className="h-10 w-10 shrink-0 rounded-full" /> 80 <Skeleton className="h-10 w-full" /> 81 </div> 82 <Skeleton className="h-20 w-full" /> 83 </div> 84 ); 85 86 export const TweetNotFound = ({ 87 className, 88 ...props 89 }: { 90 className?: string; 91 [key: string]: any; 92 }) => ( 93 <div 94 className={cn( 95 "flex h-full w-full flex-col items-center justify-center gap-2 rounded-lg border p-4", 96 className, 97 )} 98 {...props} 99 > 100 <h3>Tweet not found</h3> 101 </div> 102 ); 103 104 export const TweetHeader = ({ tweet }: { tweet: EnrichedTweet }) => ( 105 <div className="flex flex-row justify-between tracking-tight"> 106 <div className="flex items-center space-x-2"> 107 <a href={tweet.user.url} target="_blank" rel="noreferrer"> 108 <img 109 title={`Profile picture of ${tweet.user.name}`} 110 alt={tweet.user.screen_name} 111 height={48} 112 width={48} 113 src={tweet.user.profile_image_url_https} 114 className="overflow-hidden rounded-full border border-transparent" 115 /> 116 </a> 117 <div> 118 <a 119 href={tweet.user.url} 120 target="_blank" 121 rel="noreferrer" 122 className="flex items-center whitespace-nowrap font-semibold" 123 > 124 {truncate(tweet.user.name, 20)} 125 {tweet.user.verified || 126 (tweet.user.is_blue_verified && ( 127 <Verified className="ml-1 inline h-4 w-4 text-blue-500" /> 128 ))} 129 </a> 130 <div className="flex items-center space-x-1"> 131 <a 132 href={tweet.user.url} 133 target="_blank" 134 rel="noreferrer" 135 className="text-sm text-gray-500 transition-all duration-75" 136 > 137 @{truncate(tweet.user.screen_name, 16)} 138 </a> 139 </div> 140 </div> 141 </div> 142 <a href={tweet.url} target="_blank" rel="noreferrer"> 143 <span className="sr-only">Link to tweet</span> 144 <Twitter className="h-5 w-5 items-start text-[#3BA9EE] transition-all ease-in-out hover:scale-105" /> 145 </a> 146 </div> 147 ); 148 149 export const TweetBody = ({ tweet }: { tweet: EnrichedTweet }) => ( 150 <div className="break-words leading-normal tracking-tighter"> 151 {tweet.entities.map((entity, idx) => { 152 switch (entity.type) { 153 case "url": 154 case "symbol": 155 case "hashtag": 156 case "mention": 157 return ( 158 <a 159 key={idx} 160 href={entity.href} 161 target="_blank" 162 rel="noopener noreferrer" 163 className="text-sm font-normal text-gray-500" 164 > 165 <span>{entity.text}</span> 166 </a> 167 ); 168 case "text": 169 return ( 170 <span 171 key={idx} 172 className="text-sm font-normal" 173 dangerouslySetInnerHTML={{ __html: entity.text }} 174 /> 175 ); 176 } 177 })} 178 </div> 179 ); 180 181 export const TweetMedia = ({ tweet }: { tweet: EnrichedTweet }) => ( 182 <div className="flex flex-1 items-center justify-center"> 183 {tweet.video && ( 184 <video 185 poster={tweet.video.poster} 186 autoPlay 187 loop 188 muted 189 playsInline 190 className="rounded-xl border shadow-sm" 191 > 192 <source src={tweet.video.variants[0].src} type="video/mp4" /> 193 Your browser does not support the video tag. 194 </video> 195 )} 196 {tweet.photos && ( 197 <div className="relative flex transform-gpu snap-x snap-mandatory gap-4 overflow-x-auto"> 198 <div className="shrink-0 snap-center sm:w-2" /> 199 {tweet.photos.map((photo) => ( 200 <img 201 key={photo.url} 202 src={photo.url} 203 title={"Photo by " + tweet.user.name} 204 alt={tweet.text} 205 className="h-64 w-5/6 shrink-0 snap-center snap-always rounded-xl border object-cover shadow-sm" 206 /> 207 ))} 208 <div className="shrink-0 snap-center sm:w-2" /> 209 </div> 210 )} 211 {!tweet.video && 212 !tweet.photos && 213 // @ts-ignore 214 tweet?.card?.binding_values?.thumbnail_image_large?.image_value.url && ( 215 <img 216 // @ts-ignore 217 src={tweet.card.binding_values.thumbnail_image_large.image_value.url} 218 className="h-64 rounded-xl border object-cover shadow-sm" 219 /> 220 )} 221 </div> 222 ); 223 224 export const MagicTweet = ({ 225 tweet, 226 components, 227 className, 228 ...props 229 }: { 230 tweet: Tweet; 231 components?: TwitterComponents; 232 className?: string; 233 }) => { 234 const enrichedTweet = enrichTweet(tweet); 235 return ( 236 <div 237 className={cn( 238 "relative flex h-full w-full max-w-[32rem] flex-col gap-2 overflow-hidden rounded-lg border p-4 backdrop-blur-md", 239 className, 240 )} 241 {...props} 242 > 243 <TweetHeader tweet={enrichedTweet} /> 244 <TweetBody tweet={enrichedTweet} /> 245 <TweetMedia tweet={enrichedTweet} /> 246 </div> 247 ); 248 }; 249 250 /** 251 * TweetCard (Server Side Only) 252 */ 253 export const TweetCard = async ({ 254 id, 255 components, 256 fallback = <TweetSkeleton />, 257 onError, 258 ...props 259 }: TweetProps & { 260 className?: string; 261 }) => { 262 const tweet = id 263 ? await getTweet(id).catch((err) => { 264 if (onError) { 265 onError(err); 266 } else { 267 console.error(err); 268 } 269 }) 270 : undefined; 271 272 if (!tweet) { 273 const NotFound = components?.TweetNotFound || TweetNotFound; 274 return <NotFound {...props} />; 275 } 276 277 return ( 278 <Suspense fallback={fallback}> 279 <MagicTweet tweet={tweet} {...props} /> 280 </Suspense> 281 ); 282 }; 283 284 export default TweetCard;