Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add poll support #176

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/react-tweet/src/api/types/card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface Card {
name: string
url: string
binding_values: BindingValues
}

export interface BindingValues {
choice1_label: StringValue
choice2_label: StringValue
choice3_label: StringValue
choice4_label: StringValue
choice1_count: StringValue
choice2_count: StringValue
choice3_count: StringValue
choice4_count: StringValue
needim marked this conversation as resolved.
Show resolved Hide resolved
end_datetime_utc: StringValue
counts_are_final: BooleanValue
last_updated_datetime_utc: StringValue
duration_minutes: StringValue
}

export interface StringValue {
string_value: string
type: string
}

export interface BooleanValue {
boolean_value: boolean
type: string
}

export type PollChoice = {
label: string
count: number
percentage: string
}
2 changes: 2 additions & 0 deletions packages/react-tweet/src/api/types/tweet.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Card } from './card.js'
import type { TweetEditControl } from './edit.js'
import type { Indices, TweetEntities } from './entities.js'
import type { MediaDetails } from './media.js'
Expand Down Expand Up @@ -43,6 +44,7 @@ export interface TweetBase {
edit_control: TweetEditControl
isEdited: boolean
isStaleEdit: boolean
card?: Card
}

/**
Expand Down
24 changes: 24 additions & 0 deletions packages/react-tweet/src/date-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,27 @@ export const formatDate = (date: Date) => {
const formattedDate = `${parts.month} ${parts.day}, ${parts.year}`
return `${formattedTime} · ${formattedDate}`
}

export const formatRemainingTime = (date: Date) => {
needim marked this conversation as resolved.
Show resolved Hide resolved
const timeDiff = date.getTime() - Date.now()
if (timeDiff > 0) {
const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24))
const hours = Math.floor(
(timeDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
)
const minutes = Math.floor((timeDiff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((timeDiff % (1000 * 60)) / 1000)

if (days > 0) {
return `${days} days left`
} else if (hours > 0) {
return `${hours} hours left`
} else if (minutes > 0) {
return `${minutes} minutes left`
} else {
return `${seconds} seconds left`
}
} else {
return 'Final results'
}
}
1 change: 1 addition & 0 deletions packages/react-tweet/src/twitter-theme/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export * from './tweet-not-found.js'
export * from './tweet-replies.js'
export * from './tweet-skeleton.js'
export * from './quoted-tweet/index.js'
export * from './tweet-card.js'
2 changes: 2 additions & 0 deletions packages/react-tweet/src/twitter-theme/embedded-tweet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TweetReplies } from './tweet-replies.js'
import { QuotedTweet } from './quoted-tweet/index.js'
import { enrichTweet } from '../utils.js'
import { useMemo } from 'react'
import { TweetCard } from './tweet-card.js'

type Props = {
tweet: Tweet
Expand All @@ -25,6 +26,7 @@ export const EmbeddedTweet = ({ tweet: t, components }: Props) => {
<TweetHeader tweet={tweet} components={components} />
{tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
<TweetBody tweet={tweet} />
{tweet.card && <TweetCard card={tweet.card} />}
{tweet.mediaDetails?.length ? (
<TweetMedia tweet={tweet} components={components} />
) : null}
Expand Down
14 changes: 14 additions & 0 deletions packages/react-tweet/src/twitter-theme/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
--tweet-body-line-height: 1.5rem;
--tweet-body-margin: 0;

/* Poll */
--tweet-poll-container-margin: 1rem 0 0;
--tweet-poll-choice-margin: 0.25rem 0;
--tweet-poll-choice-border: none;
--tweet-poll-choice-border-radius: 4px;
--tweet-poll-choice-padding: 0.25rem 0.8rem;
--tweet-poll-choice-text-align: left;
--tweet-poll-choice-bar-background: rgb(207, 217, 222);
--tweet-poll-choice-bar-border-radius: 4px;
--tweet-poll-choice-percent-font-weight: 700;

/* Quoted Tweet */
--tweet-quoted-container-margin: 0.75rem 0;
--tweet-quoted-body-font-size: 0.938rem;
Expand Down Expand Up @@ -71,6 +82,7 @@
--tweet-twitter-icon-color: var(--tweet-font-color);
--tweet-verified-old-color: rgb(130, 154, 171);
--tweet-verified-blue-color: var(--tweet-color-blue-primary);
--tweet-poll-choice-bar-background: rgb(207, 217, 222);
}

:is([data-theme='dark'], .dark) :where(.react-tweet-theme) {
Expand Down Expand Up @@ -100,6 +112,7 @@
--tweet-twitter-icon-color: var(--tweet-font-color);
--tweet-verified-old-color: rgb(130, 154, 171);
--tweet-verified-blue-color: #fff;
--tweet-poll-choice-bar-background: rgb(65, 84, 100);
}

@media (prefers-color-scheme: dark) {
Expand Down Expand Up @@ -129,5 +142,6 @@
--tweet-twitter-icon-color: var(--tweet-font-color);
--tweet-verified-old-color: rgb(130, 154, 171);
--tweet-verified-blue-color: #fff;
--tweet-poll-choice-bar-background: rgb(65, 84, 100);
}
}
47 changes: 47 additions & 0 deletions packages/react-tweet/src/twitter-theme/tweet-card.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.root {
font-size: var(--tweet-body-font-size);
font-weight: var(--tweet-body-font-weight);
line-height: var(--tweet-body-line-height);
margin: var(--tweet-body-margin);
overflow-wrap: break-word;
white-space: pre-wrap;
margin: var(--tweet-poll-container-margin);
font-size: var(--tweet-quoted-body-font-size);
}

.choice {
display: flex;
flex-direction: column;
align-items: center;
margin: var(--tweet-poll-choice-margin);
border: var(--tweet-poll-choice-border);
border-radius: var(--tweet-poll-choice-border-radius);
position: relative;
padding: var(--tweet-poll-choice-padding);
text-align: var(--tweet-poll-choice-text-align);
letter-spacing: -0.025em;
font-weight: 500;
}

.bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--tweet-poll-choice-bar-background);
z-index: 0;
border-radius: var(--tweet-poll-choice-bar-border-radius);
}

.label {
width: 100%;
z-index: 1;
}

.percent {
position: absolute;
z-index: 1;
right: 0;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
104 changes: 104 additions & 0 deletions packages/react-tweet/src/twitter-theme/tweet-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import clsx from 'clsx'
import {
BindingValues,
Card,
PollChoice,
StringValue,
} from '../api/types/card.js'
import s from './tweet-card.module.css'
import infoStyles from './tweet-info.module.css'
import { formatRemainingTime } from '../date-utils.js'
import { formatVoteCount } from '../utils.js'

export const TweetCard = ({ card }: { card: Card }) => {
// Poll card, e.g. https://x.com/elonmusk/status/1604617643973124097
return card.name.startsWith('poll') ? (
<TweetPoll poll={card.binding_values} />
) : undefined
needim marked this conversation as resolved.
Show resolved Hide resolved
}

const TweetPoll = ({ poll }: { poll: BindingValues }) => {
needim marked this conversation as resolved.
Show resolved Hide resolved
const totalChoiceCount = Object.keys(poll).filter(
(key) => key.startsWith('choice') && key.endsWith('_label')
).length

const totalVoteCount = Object.keys(poll)
.filter((key) => key.startsWith('choice') && key.endsWith('_count'))
.reduce((acc, key) => {
return (
acc +
parseInt((poll[key as keyof BindingValues] as StringValue).string_value)
)
}, 0)

const choicePercentages = Array.from({ length: totalChoiceCount }, (_, i) => {
const choiceCount = (
poll[`choice${i + 1}_count` as keyof BindingValues] as StringValue
).string_value
return ((parseInt(choiceCount) / totalVoteCount) * 100).toFixed(1)
})

const choices: PollChoice[] = Array.from(
{ length: totalChoiceCount },
(_, i) => {
const choiceLabel = (
poll[`choice${i + 1}_label` as keyof BindingValues] as StringValue
).string_value
const choiceCount = parseInt(
(poll[`choice${i + 1}_count` as keyof BindingValues] as StringValue)
.string_value
)
return {
label: choiceLabel,
count: choiceCount,
percentage: choicePercentages[i],
}
}
)

return (
<div className={s.root}>
{choices.map((choice, i) => (
<TweetPollChoice key={i} choice={choice} />
))}
<TweetPollInfo
voteCount={totalVoteCount}
endDateTime={poll.end_datetime_utc.string_value}
/>
</div>
)
}

const TweetPollInfo = ({
voteCount,
endDateTime,
}: {
voteCount: number
endDateTime: string
}) => {
return (
<div className={clsx(infoStyles.info)}>
<span>
{formatVoteCount(voteCount)} votes ·{' '}
{formatRemainingTime(new Date(endDateTime))}
</span>
</div>
)
}

const TweetPollChoice = ({ choice }: { choice: PollChoice }) => {
return (
<div className={s.choice} title={`Votes: ${formatVoteCount(choice.count)}`}>
<div>
<div
className={s.bar}
style={{
width: `${choice.percentage}%`,
}}
></div>
<div className={s.percent}>{choice.percentage}%</div>
</div>
<div className={s.label}>{choice.label}</div>
</div>
)
}
5 changes: 5 additions & 0 deletions packages/react-tweet/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export const formatNumber = (n: number): string => {
return n.toString()
}

export const formatVoteCount = (n: number): string => {
return new Intl.NumberFormat('en-US').format(n)
}

type TextEntity = {
indices: Indices
type: 'text'
Expand Down Expand Up @@ -247,4 +251,5 @@ export const enrichTweet = (tweet: Tweet): EnrichedTweet => ({
entities: getEntities(tweet.quoted_tweet),
}
: undefined,
card: tweet.card ? tweet.card : undefined,
})