Add repost flag and nested quoted-tweet frame to TweetCard
Nitter's RSS marks a bare retweet with a "RT by @handle:" prefix on the item's <title> (dc:creator is already the original author, not the retweeter — confirmed against a real sample). TweetCard now shows a "🔁 Reposted by @handle" line above an otherwise-unchanged card. A quote-tweet's RSS description carries the embedded tweet fully inline in a <blockquote> (author, text, one image, permalink) — no extra fxtwitter call needed. TweetCard renders it as a smaller frame nested inside the same outer card, below the quoting tweet's own text and media, labeled "↩️ Replying to @handle" per how this reads to a visitor even though it's technically Nitter's quote-tweet representation. Clicking it opens that tweet's own permalink, independent of the outer card's link. Both parsers verified against the real sample RSS (Polymarket/ rawsalerts retweet, Goldman/zerohedge quote-tweet) and against the live publishDirect pipeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import type { Source, ContentItem, TweetMediaItem } from '../../storage/db/types.js';
|
import type { Source, ContentItem, TweetMediaItem, QuotedTweet } from '../../storage/db/types.js';
|
||||||
import { cleanHtml, toSummary } from '../clean.js';
|
import { cleanHtml, toSummary } from '../clean.js';
|
||||||
|
|
||||||
export interface FetchedItem {
|
export interface FetchedItem {
|
||||||
@@ -10,7 +10,15 @@ export interface FetchedItem {
|
|||||||
link: string;
|
link: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
/** Set by the Nitter adapter only — carries the tweet's author info through to ContentItem.tweet. */
|
/** Set by the Nitter adapter only — carries the tweet's author info through to ContentItem.tweet. */
|
||||||
tweet?: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] };
|
tweet?: {
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
authorHandle: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
media: TweetMediaItem[];
|
||||||
|
repostedByHandle: string | null;
|
||||||
|
quotedTweet: QuotedTweet | null;
|
||||||
|
};
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
// two outlets' coverage of the same news event does — same reasoning as YouTube.
|
// two outlets' coverage of the same news event does — same reasoning as YouTube.
|
||||||
|
|
||||||
import Parser from 'rss-parser';
|
import Parser from 'rss-parser';
|
||||||
import type { Source, TweetMediaItem } from '../../storage/db/types.js';
|
import type { Source, TweetMediaItem, QuotedTweet } from '../../storage/db/types.js';
|
||||||
import type { SourceAdapter, FetchedItem } from './base.js';
|
import type { SourceAdapter, FetchedItem } from './base.js';
|
||||||
import { logger } from '../../storage/db/logs.js';
|
import { logger } from '../../storage/db/logs.js';
|
||||||
import { getSettings } from '../../storage/db/settings.js';
|
import { getSettings } from '../../storage/db/settings.js';
|
||||||
|
import { cleanHtml, toSummary } from '../clean.js';
|
||||||
|
|
||||||
/** fxtwitter caps a tweet at 4 attached photos/videos/gifs, in tweet display order. */
|
/** fxtwitter caps a tweet at 4 attached photos/videos/gifs, in tweet display order. */
|
||||||
const MAX_TWEET_MEDIA = 4;
|
const MAX_TWEET_MEDIA = 4;
|
||||||
@@ -101,6 +102,46 @@ function extractImageUrl(html: string): string | null {
|
|||||||
return html.match(/<img[^>]+src="([^"]+)"/i)?.[1] ?? null;
|
return html.match(/<img[^>]+src="([^"]+)"/i)?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bare retweet's RSS title is prefixed "RT by @handle: ..." — confirmed against a real
|
||||||
|
* sample (e.g. `<title>RT by @rawsalerts: BREAKING: MyPillow CEO...</title>` with
|
||||||
|
* `<dc:creator>@Polymarket</dc:creator>`, i.e. dc:creator is already the original author;
|
||||||
|
* only the retweeter's handle is missing from anywhere else in the feed). No blockquote
|
||||||
|
* accompanies a bare retweet — the description is the original tweet's own content directly.
|
||||||
|
*/
|
||||||
|
function extractRepostedByHandle(title: string | undefined): string | null {
|
||||||
|
return title?.match(/^RT by @(\w+):/i)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quote-tweet's RSS description is the quoter's own commentary followed by an `<hr/>`
|
||||||
|
* and a `<blockquote>` wrapping the quoted tweet — confirmed against a real sample:
|
||||||
|
* `<blockquote><b>zerohedge (@zerohedge)</b><p>...quoted text...<img .../></p>
|
||||||
|
* <footer>— <cite><a href="https://.../status/...">...</a></cite></footer></blockquote>`.
|
||||||
|
* Nitter carries the quoted tweet's author, text, one image, and permalink fully inline —
|
||||||
|
* no extra fxtwitter call needed to render it as a nested preview.
|
||||||
|
*/
|
||||||
|
function extractQuotedTweet(descriptionHtml: string): QuotedTweet | null {
|
||||||
|
const blockquoteMatch = descriptionHtml.match(/<blockquote>([\s\S]*?)<\/blockquote>/i);
|
||||||
|
if (!blockquoteMatch) return null;
|
||||||
|
const inner = blockquoteMatch[1];
|
||||||
|
|
||||||
|
const authorMatch = inner.match(/<b>\s*([^<(]+?)\s*\(@([^)]+)\)\s*<\/b>/i);
|
||||||
|
const linkMatch = inner.match(/<footer>[\s\S]*?<a href="([^"]+)"/i);
|
||||||
|
if (!authorMatch || !linkMatch) return null;
|
||||||
|
|
||||||
|
const afterAuthor = inner.slice((authorMatch.index ?? 0) + authorMatch[0].length);
|
||||||
|
const beforeFooter = afterAuthor.replace(/<footer>[\s\S]*$/i, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorName: authorMatch[1].trim(),
|
||||||
|
authorHandle: authorMatch[2].trim(),
|
||||||
|
text: toSummary(cleanHtml(beforeFooter), 240),
|
||||||
|
imageUrl: extractImageUrl(inner),
|
||||||
|
link: linkMatch[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const nitterAdapter: SourceAdapter = {
|
export const nitterAdapter: SourceAdapter = {
|
||||||
async fetch(source: Source): Promise<FetchedItem[]> {
|
async fetch(source: Source): Promise<FetchedItem[]> {
|
||||||
if (!source.url) return [];
|
if (!source.url) return [];
|
||||||
@@ -116,16 +157,17 @@ export const nitterAdapter: SourceAdapter = {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// dc:creator (rss-parser maps it to item.creator) is actually whichever list
|
// dc:creator (rss-parser maps it to item.creator) is reliably the author of the
|
||||||
// member's retweet/reply surfaced this item in the feed — NOT the original
|
// tweet actually being shown — confirmed against a real "RT by @X: ..." sample,
|
||||||
// tweet's author for a retweet. It's only used here to look up the fxtwitter
|
// where dc:creator was the ORIGINAL author, not the retweeter (the retweeter's
|
||||||
// enrichment (fxtwitter needs *a* handle + the tweet ID, and any handle works
|
// handle only ever appears in the title's "RT by @X:" prefix, extracted below).
|
||||||
// for that lookup); the author identity actually displayed always comes from
|
|
||||||
// the enrichment response below, keyed consistently off the same tweet — so
|
|
||||||
// name and handle never end up describing two different people.
|
|
||||||
const rssHandle = (item.creator ?? '').replace(/^@/, '') || 'unknown';
|
const rssHandle = (item.creator ?? '').replace(/^@/, '') || 'unknown';
|
||||||
const ownHtml = ownContentHtml(item.content ?? '');
|
const ownHtml = ownContentHtml(item.content ?? '');
|
||||||
const rssImageUrl = extractImageUrl(ownHtml);
|
const rssImageUrl = extractImageUrl(ownHtml);
|
||||||
|
const repostedByHandle = extractRepostedByHandle(item.title);
|
||||||
|
// A bare retweet never carries a blockquote (its description IS the original
|
||||||
|
// tweet directly), so this is naturally null whenever repostedByHandle is set.
|
||||||
|
const quotedTweet = extractQuotedTweet(item.content ?? '');
|
||||||
|
|
||||||
const enrichment = await fetchFxTwitter(rssHandle, tweetId);
|
const enrichment = await fetchFxTwitter(rssHandle, tweetId);
|
||||||
|
|
||||||
@@ -151,7 +193,7 @@ export const nitterAdapter: SourceAdapter = {
|
|||||||
videos: [],
|
videos: [],
|
||||||
link: item.link,
|
link: item.link,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media },
|
tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media, repostedByHandle, quotedTweet },
|
||||||
raw: { rss: item, fxtwitter: enrichment }
|
raw: { rss: item, fxtwitter: enrichment }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { logger } from '../storage/db/logs.js';
|
|||||||
import * as articles from '../storage/db/articles.js';
|
import * as articles from '../storage/db/articles.js';
|
||||||
import * as tags from '../storage/db/tags.js';
|
import * as tags from '../storage/db/tags.js';
|
||||||
import * as sources from '../storage/db/sources.js';
|
import * as sources from '../storage/db/sources.js';
|
||||||
import type { GlobalSettings, MergedArticle, ContentItem, TweetMediaItem } from '../storage/db/types.js';
|
import type { GlobalSettings, MergedArticle, ContentItem, TweetMediaItem, QuotedTweet } from '../storage/db/types.js';
|
||||||
|
|
||||||
const FOLLOW_UP_LOOKBACK_DAYS = 3;
|
const FOLLOW_UP_LOOKBACK_DAYS = 3;
|
||||||
|
|
||||||
@@ -129,6 +129,16 @@ async function resolveTweetMedia(
|
|||||||
return { media: resolved, storedMediaIds };
|
return { media: resolved, storedMediaIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves a quote-tweet's embedded image (if any) through the Nitter media mode, same as any other tweet media. */
|
||||||
|
async function resolveQuotedTweet(
|
||||||
|
quoted: QuotedTweet,
|
||||||
|
mode: GlobalSettings['nitterMediaMode']
|
||||||
|
): Promise<{ quotedTweet: QuotedTweet; storedMediaId: string | null }> {
|
||||||
|
if (!quoted.imageUrl) return { quotedTweet: quoted, storedMediaId: null };
|
||||||
|
const resolved = await resolveTweetMediaUrl(quoted.imageUrl, mode);
|
||||||
|
return { quotedTweet: { ...quoted, imageUrl: resolved.url }, storedMediaId: resolved.storedMediaId };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes a single item as-is, with no AI calls at all — used when the AI service
|
* Publishes a single item as-is, with no AI calls at all — used when the AI service
|
||||||
* isn't reachable (e.g. Ollama hasn't been set up yet, per the "assume it arrives
|
* isn't reachable (e.g. Ollama hasn't been set up yet, per the "assume it arrives
|
||||||
@@ -165,12 +175,22 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
|
|||||||
}
|
}
|
||||||
const resolvedMedia = await resolveTweetMedia(item.tweet.media, settings.nitterMediaMode);
|
const resolvedMedia = await resolveTweetMedia(item.tweet.media, settings.nitterMediaMode);
|
||||||
storedMediaIds.push(...resolvedMedia.storedMediaIds);
|
storedMediaIds.push(...resolvedMedia.storedMediaIds);
|
||||||
|
|
||||||
|
let quotedTweet: QuotedTweet | null = null;
|
||||||
|
if (item.tweet.quotedTweet) {
|
||||||
|
const resolvedQuoted = await resolveQuotedTweet(item.tweet.quotedTweet, settings.nitterMediaMode);
|
||||||
|
quotedTweet = resolvedQuoted.quotedTweet;
|
||||||
|
if (resolvedQuoted.storedMediaId) storedMediaIds.push(resolvedQuoted.storedMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
tweet = {
|
tweet = {
|
||||||
authorName: item.tweet.authorName,
|
authorName: item.tweet.authorName,
|
||||||
authorHandle: item.tweet.authorHandle,
|
authorHandle: item.tweet.authorHandle,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
sourceItemId: item.id,
|
sourceItemId: item.id,
|
||||||
media: resolvedMedia.media
|
media: resolvedMedia.media,
|
||||||
|
repostedByHandle: item.tweet.repostedByHandle,
|
||||||
|
quotedTweet
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export interface TweetMediaItem {
|
|||||||
height: number | null;
|
height: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tweet embedded inside a quote-tweet's own <blockquote> — Nitter's RSS carries this
|
||||||
|
* fully inline (author, text, one image, permalink), so no extra fxtwitter call is needed
|
||||||
|
* to render it. Rendered as a smaller frame nested inside the quoting tweet's card.
|
||||||
|
*/
|
||||||
|
export interface QuotedTweet {
|
||||||
|
authorName: string;
|
||||||
|
authorHandle: string;
|
||||||
|
text: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContentItem {
|
export interface ContentItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
@@ -42,7 +55,17 @@ export interface ContentItem {
|
|||||||
eventId: string | null;
|
eventId: string | null;
|
||||||
clusterId: string | null;
|
clusterId: string | null;
|
||||||
/** Nitter-sourced items only — null for everything else. */
|
/** Nitter-sourced items only — null for everything else. */
|
||||||
tweet: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] } | null;
|
tweet: {
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
authorHandle: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
media: TweetMediaItem[];
|
||||||
|
/** Set when this item is a bare retweet — the retweeter's handle, e.g. from RSS "RT by @X:". */
|
||||||
|
repostedByHandle: string | null;
|
||||||
|
/** Set when this item is a quote-tweet — the tweet embedded in its <blockquote>. */
|
||||||
|
quotedTweet: QuotedTweet | null;
|
||||||
|
} | null;
|
||||||
raw: unknown;
|
raw: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +83,15 @@ export interface MergedArticle {
|
|||||||
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
||||||
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
|
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
|
||||||
/** Nitter-sourced articles only — the embed card's author info and attached media (see TweetCard.svelte). Never set alongside video. */
|
/** Nitter-sourced articles only — the embed card's author info and attached media (see TweetCard.svelte). Never set alongside video. */
|
||||||
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
|
tweet: {
|
||||||
|
authorName: string;
|
||||||
|
authorHandle: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
sourceItemId: string;
|
||||||
|
media: TweetMediaItem[];
|
||||||
|
repostedByHandle: string | null;
|
||||||
|
quotedTweet: QuotedTweet | null;
|
||||||
|
} | null;
|
||||||
category: string[];
|
category: string[];
|
||||||
geo: string | null;
|
geo: string | null;
|
||||||
eventId: string | null;
|
eventId: string | null;
|
||||||
|
|||||||
@@ -26,9 +26,19 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.open(resolveMediaUrl(url), '_blank', 'noopener,noreferrer');
|
window.open(resolveMediaUrl(url), '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The nested quoted-tweet frame opens its own permalink — a Nitter URL already, not
|
||||||
|
// backend-hosted media, so no resolveMediaUrl needed here.
|
||||||
|
function openQuoted(e: MouseEvent, link: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(link, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="tweet-card" href={tweetUrl} target="_blank" rel="noreferrer">
|
<a class="tweet-card" href={tweetUrl} target="_blank" rel="noreferrer">
|
||||||
|
{#if article.tweet?.repostedByHandle}
|
||||||
|
<div class="repost-line">🔁 Reposted by @{article.tweet.repostedByHandle}</div>
|
||||||
|
{/if}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>{article.category[0] ?? ''}</span>
|
<span>{article.category[0] ?? ''}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -72,6 +82,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if article.tweet?.quotedTweet}
|
||||||
|
{@const quoted = article.tweet.quotedTweet}
|
||||||
|
<div class="quote-label">↩️ Replying to @{quoted.authorHandle}</div>
|
||||||
|
<button type="button" class="quoted-card" onclick={(e) => openQuoted(e, quoted.link)}>
|
||||||
|
<div class="quoted-author-row">
|
||||||
|
<span class="quoted-name">{quoted.authorName}</span>
|
||||||
|
<span class="quoted-handle">@{quoted.authorHandle}</span>
|
||||||
|
</div>
|
||||||
|
<div class="quoted-text">{quoted.text}</div>
|
||||||
|
{#if quoted.imageUrl}
|
||||||
|
<img class="quoted-img" src={resolveMediaUrl(quoted.imageUrl)} alt="" loading="lazy" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<div class="time">{timeAgo(article.publishedAt)} · {exactTime(article.publishedAt)}</div>
|
<div class="time">{timeAgo(article.publishedAt)} · {exactTime(article.publishedAt)}</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -89,6 +113,12 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-color: var(--border-accent);
|
border-color: var(--border-accent);
|
||||||
}
|
}
|
||||||
|
.repost-line {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
@@ -182,6 +212,61 @@
|
|||||||
display: block;
|
display: block;
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
}
|
}
|
||||||
|
/* Nested inside the same outer frame — deliberately lighter-weight than the card
|
||||||
|
itself (no independent hover border, tighter radius, tinted background instead of
|
||||||
|
its own border-forward "card" look) so it reads as inset content, not a second card. */
|
||||||
|
.quote-label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.quoted-card {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.quoted-author-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.quoted-name {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.quoted-handle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-line;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.quoted-img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
.time {
|
.time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -17,13 +17,30 @@ export interface TweetMediaItem {
|
|||||||
height: number | null;
|
height: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tweet embedded in a quote-tweet's own preview — rendered as a smaller nested frame. */
|
||||||
|
export interface QuotedTweet {
|
||||||
|
authorName: string;
|
||||||
|
authorHandle: string;
|
||||||
|
text: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MergedArticle {
|
export interface MergedArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
|
||||||
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
|
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
|
||||||
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
|
tweet: {
|
||||||
|
authorName: string;
|
||||||
|
authorHandle: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
sourceItemId: string;
|
||||||
|
media: TweetMediaItem[];
|
||||||
|
repostedByHandle: string | null;
|
||||||
|
quotedTweet: QuotedTweet | null;
|
||||||
|
} | null;
|
||||||
category: string[];
|
category: string[];
|
||||||
geo: string | null;
|
geo: string | null;
|
||||||
eventId: string | null;
|
eventId: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user