/ app / src / components / TweetCard.tsx
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;