Skip to content

Commit 354c296

Browse files
authored
add support for quoted tweets (#117)
* add support for quoted tweets * fix imports * tweak tweet media for quoted media
1 parent 5903b0c commit 354c296

19 files changed

+249
-48
lines changed

packages/react-tweet/src/api/get-tweet.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export async function getTweet(
5555
'tfw_tweet_edit_frontend:on',
5656
].join(';')
5757
)
58-
58+
5959
const res = await fetch(url.toString(), fetchOptions)
6060
const isJson = res.headers.get('content-type')?.includes('application/json')
6161
const data = isJson ? await res.json() : undefined

packages/react-tweet/src/api/types/tweet.ts

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export interface QuotedTweet extends TweetBase {
8080
reply_count: number
8181
retweet_count: number
8282
favorite_count: number
83+
mediaDetails?: MediaDetails[]
8384
self_thread: {
8485
id_str: string
8586
}

packages/react-tweet/src/twitter-theme/embedded-tweet.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TweetMedia } from './tweet-media.js'
88
import { TweetInfo } from './tweet-info.js'
99
import { TweetActions } from './tweet-actions.js'
1010
import { TweetReplies } from './tweet-replies.js'
11+
import { TweetQuotedTweet } from './tweet-quoted-tweet.js'
1112
import { enrichTweet } from '../utils.js'
1213
import { useMemo } from 'react'
1314

@@ -27,6 +28,7 @@ export const EmbeddedTweet = ({ tweet: t, components }: Props) => {
2728
{tweet.mediaDetails?.length ? (
2829
<TweetMedia tweet={tweet} components={components} />
2930
) : null}
31+
{tweet.quoted_tweet && <TweetQuotedTweet tweet={tweet.quoted_tweet} />}
3032
<TweetInfo tweet={tweet} />
3133
<TweetActions tweet={tweet} />
3234
<TweetReplies tweet={tweet} />

packages/react-tweet/src/twitter-theme/theme.css

+8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
--tweet-body-line-height: 1.5rem;
1212
--tweet-body-margin: 0;
1313

14+
/* Quoted */
15+
--tweet-quoted-body-font-size: 0.938rem;
16+
--tweet-quoted-body-font-weight: 400;
17+
--tweet-quoted-body-line-height: 1.25rem;
18+
--tweet-quoted-body-margin: 0.25rem 0 0.75rem 0;
19+
--tweet-quoted-container-margin: 0.75rem 0;
20+
21+
1422
/* Info */
1523
--tweet-info-font-size: 0.9375rem;
1624
--tweet-info-line-height: 1.25rem;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.verifiedOld {
2+
color: var(--tweet-verified-old-color);
3+
}
4+
.verifiedBlue {
5+
color: var(--tweet-verified-blue-color);
6+
}
7+
.verifiedGovernment {
8+
/* color: var(--tweet-verified-government-color); */
9+
color: rgb(130, 154, 171);
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import clsx from 'clsx'
2+
import s from './tweet-author-verified-badge.module.css'
3+
import {
4+
Verified,
5+
VerifiedBusiness,
6+
VerifiedGovernment,
7+
} from './icons/index.js'
8+
import { TweetUser } from '../api'
9+
10+
type Props = {
11+
user: TweetUser
12+
className?: string
13+
}
14+
15+
export const TweetAuthorVerifiedBadge = ({ user, className }: Props) => {
16+
const verified = user.verified || user.is_blue_verified || user.verified_type
17+
let icon = <Verified />
18+
let iconClassName: string | null = s.verifiedBlue
19+
20+
if (verified) {
21+
if (!user.is_blue_verified) {
22+
iconClassName = s.verifiedOld
23+
}
24+
switch (user.verified_type) {
25+
case 'Government':
26+
icon = <VerifiedGovernment />
27+
iconClassName = s.verifiedGovernment
28+
break
29+
case 'Business':
30+
icon = <VerifiedBusiness />
31+
iconClassName = null
32+
break
33+
}
34+
}
35+
return (
36+
verified && <div className={clsx(className, iconClassName)}>{icon}</div>
37+
)
38+
}

packages/react-tweet/src/twitter-theme/tweet-header.module.css

+1-10
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,7 @@
5959
overflow: hidden;
6060
white-space: nowrap;
6161
}
62-
.verifiedOld {
63-
color: var(--tweet-verified-old-color);
64-
}
65-
.verifiedBlue {
66-
color: var(--tweet-verified-blue-color);
67-
}
68-
.verifiedGovernment {
69-
/* color: var(--tweet-verified-government-color); */
70-
color: rgb(130, 154, 171);
71-
}
62+
7263
.authorMeta {
7364
display: flex;
7465
}

packages/react-tweet/src/twitter-theme/tweet-header.tsx

+2-27
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@ import clsx from 'clsx'
22
import type { EnrichedTweet } from '../utils.js'
33
import type { TwitterComponents } from './types.js'
44
import { AvatarImg } from './avatar-img.js'
5-
import {
6-
Verified,
7-
VerifiedGovernment,
8-
VerifiedBusiness,
9-
} from './icons/index.js'
105
import s from './tweet-header.module.css'
6+
import { TweetAuthorVerifiedBadge } from './tweet-author-verified-badge.js'
117

128
type Props = {
139
tweet: EnrichedTweet
@@ -17,25 +13,6 @@ type Props = {
1713
export const TweetHeader = ({ tweet, components }: Props) => {
1814
const Img = components?.AvatarImg ?? AvatarImg
1915
const { user } = tweet
20-
const verified = user.verified || user.is_blue_verified || user.verified_type
21-
let icon = <Verified />
22-
let iconClassName: string | null = s.verifiedBlue
23-
24-
if (verified) {
25-
if (!user.is_blue_verified) {
26-
iconClassName = s.verifiedOld
27-
}
28-
switch (user.verified_type) {
29-
case 'Government':
30-
icon = <VerifiedGovernment />
31-
iconClassName = s.verifiedGovernment
32-
break
33-
case 'Business':
34-
icon = <VerifiedBusiness />
35-
iconClassName = null
36-
break
37-
}
38-
}
3916

4017
return (
4118
<div className={s.header}>
@@ -72,9 +49,7 @@ export const TweetHeader = ({ tweet, components }: Props) => {
7249
<div className={s.authorLinkText}>
7350
<span title={user.name}>{user.name}</span>
7451
</div>
75-
{verified && (
76-
<div className={clsx(s.authorVerified, iconClassName)}>{icon}</div>
77-
)}
52+
<TweetAuthorVerifiedBadge user={user} className={s.authorVerified} />
7853
</a>
7954
<div className={s.authorMeta}>
8055
<a

packages/react-tweet/src/twitter-theme/tweet-media-video.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import { useState } from 'react'
44
import clsx from 'clsx'
55
import type { MediaAnimatedGif, MediaVideo } from '../api/index.js'
6-
import { type EnrichedTweet, getMediaUrl, getMp4Video } from '../utils.js'
6+
import { EnrichedQuotedTweet, type EnrichedTweet, getMediaUrl, getMp4Video } from '../utils.js'
77
import mediaStyles from './tweet-media.module.css'
88
import s from './tweet-media-video.module.css'
99

1010
type Props = {
11-
tweet: EnrichedTweet
11+
tweet: EnrichedTweet | EnrichedQuotedTweet
1212
media: MediaAnimatedGif | MediaVideo
1313
}
1414

packages/react-tweet/src/twitter-theme/tweet-media.module.css

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
overflow: hidden;
66
position: relative;
77
}
8+
.rootQuoted {
9+
border: none;
10+
border-radius: 0;
11+
}
812
.mediaWrapper {
913
display: grid;
1014
grid-auto-rows: 1fr;

packages/react-tweet/src/twitter-theme/tweet-media.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Fragment } from 'react'
22
import clsx from 'clsx'
3-
import { type EnrichedTweet, getMediaUrl } from '../utils.js'
3+
import {
4+
type EnrichedQuotedTweet,
5+
type EnrichedTweet,
6+
getMediaUrl,
7+
} from '../utils.js'
48
import { MediaDetails } from '../api/index.js'
59
import type { TwitterComponents } from './types.js'
610
import { TweetMediaVideo } from './tweet-media-video.js'
@@ -25,16 +29,17 @@ const getSkeletonStyle = (media: MediaDetails, itemCount: number) => {
2529
}
2630

2731
type Props = {
28-
tweet: EnrichedTweet
32+
tweet: EnrichedTweet | EnrichedQuotedTweet
2933
components?: TwitterComponents
34+
quoted?: boolean
3035
}
3136

32-
export const TweetMedia = ({ tweet, components }: Props) => {
37+
export const TweetMedia = ({ tweet, components, quoted }: Props) => {
3338
const length = tweet.mediaDetails?.length ?? 0
3439
const Img = components?.MediaImg ?? MediaImg
3540

3641
return (
37-
<div className={s.root}>
42+
<div className={clsx(s.root, quoted && s.rootQuoted)}>
3843
<div
3944
className={clsx(
4045
s.mediaWrapper,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.root {
2+
font-size: var(--tweet-quoted-body-font-size);
3+
font-weight: var(--tweet-quoted-body-font-weight);
4+
line-height: var(--tweet-quoted-body-line-height);
5+
margin: var(--tweet-quoted-body-margin);
6+
overflow-wrap: break-word;
7+
white-space: pre-wrap;
8+
padding: 0 0.75rem;
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { EnrichedQuotedTweet } from '../utils.js'
2+
import s from './tweet-quoted-tweet-body.module.css'
3+
4+
export const TweetQuotedTweetBody = ({
5+
tweet,
6+
}: {
7+
tweet: EnrichedQuotedTweet
8+
}) => (
9+
<p className={s.root}>
10+
{tweet.entities.map((item, i) => (
11+
<span key={i} dangerouslySetInnerHTML={{ __html: item.text }} />
12+
))}
13+
</p>
14+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.root {
2+
width: 100%;
3+
overflow: hidden;
4+
/* Base font styles */
5+
border: var(--tweet-border);
6+
border-radius: 12px;
7+
margin: var(--tweet-quoted-container-margin);
8+
}
9+
10+
.article {
11+
position: relative;
12+
box-sizing: inherit;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ReactNode } from 'react'
2+
import s from './tweet-quoted-tweet-container.module.css'
3+
4+
type Props = { children: ReactNode }
5+
6+
export const TweetQuotedTweetContainer = ({ children }: Props) => (
7+
<div className={s.root}>
8+
<article className={s.article}>{children}</article>
9+
</div>
10+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
.header {
2+
display: flex;
3+
padding: 0.75rem 0.75rem 0 0.75rem;
4+
line-height: var(--tweet-header-line-height);
5+
font-size: var(--tweet-header-font-size);
6+
white-space: nowrap;
7+
overflow-wrap: break-word;
8+
overflow: hidden;
9+
10+
}
11+
12+
.avatar {
13+
position: relative;
14+
height: 20px;
15+
width: 20px;
16+
}
17+
18+
.avatarSquare {
19+
border-radius: 4px;
20+
}
21+
22+
.author {
23+
display: flex;
24+
margin: 0 0.5rem;
25+
}
26+
27+
.authorText {
28+
font-weight: 700;
29+
text-overflow: ellipsis;
30+
overflow: hidden;
31+
white-space: nowrap;
32+
}
33+
34+
.username {
35+
color: var(--tweet-font-color-secondary);
36+
text-decoration: none;
37+
text-overflow: ellipsis;
38+
margin-left: 0.125rem;
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import clsx from 'clsx'
2+
import { AvatarImg } from './avatar-img.js'
3+
import s from './tweet-quoted-tweet-header.module.css'
4+
import type { EnrichedQuotedTweet } from '../utils'
5+
import { TweetAuthorVerifiedBadge } from './tweet-author-verified-badge.js'
6+
7+
type Props = {
8+
tweet: EnrichedQuotedTweet
9+
}
10+
11+
export const TweetQuotedTweetHeader = ({ tweet }: Props) => {
12+
const { user } = tweet
13+
14+
return (
15+
<div className={s.header}>
16+
<a
17+
href={tweet.url}
18+
className={s.avatar}
19+
target="_blank"
20+
rel="noopener noreferrer"
21+
>
22+
<div
23+
className={clsx(
24+
s.avatarOverflow,
25+
user.profile_image_shape === 'Square' && s.avatarSquare
26+
)}
27+
>
28+
<AvatarImg
29+
src={user.profile_image_url_https}
30+
alt={user.name}
31+
width={20}
32+
height={20}
33+
/>
34+
</div>
35+
</a>
36+
<div className={s.author}>
37+
<div className={s.authorText}>
38+
<span title={user.name}>{user.name}</span>
39+
</div>
40+
<TweetAuthorVerifiedBadge className={s.authorVerified} user={user} />
41+
<div className={s.username}>
42+
<span title={`@${user.screen_name}`}>@{user.screen_name}</span>
43+
</div>
44+
</div>
45+
</div>
46+
)
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { QuotedTweet } from '../api'
2+
import { TweetQuotedTweetHeader } from './tweet-quoted-tweet-header.js'
3+
import { TweetQuotedTweetBody } from './tweet-quoted-tweet-body.js'
4+
import { TweetQuotedTweetContainer } from './tweet-quoted-tweet-container.js'
5+
import { TweetMedia } from './tweet-media.js'
6+
import { useMemo } from 'react'
7+
import { enrichQuotedTweet } from '../utils.js'
8+
9+
type Props = {
10+
tweet: QuotedTweet
11+
}
12+
13+
export const TweetQuotedTweet = ({ tweet: t }: Props) => {
14+
const tweet = useMemo(() => enrichQuotedTweet(t), [t])
15+
16+
return (
17+
<TweetQuotedTweetContainer>
18+
<TweetQuotedTweetHeader tweet={tweet} />
19+
<TweetQuotedTweetBody tweet={tweet} />
20+
{tweet.mediaDetails?.length ? <TweetMedia quoted tweet={tweet} /> : null}
21+
</TweetQuotedTweetContainer>
22+
)
23+
}

0 commit comments

Comments
 (0)