Merge remote-tracking branch 'origin/master' into development
# Conflicts: # backend/src/ingestion/adapters/base.ts # backend/src/pipeline/publish.ts # backend/src/storage/db/types.ts # frontend/src/lib/types.ts
This commit is contained in:
@@ -1,4 +1,11 @@
|
|||||||
import type { Source, ContentItem, TweetMediaItem, TelegramMediaRef, TelegramForwardedFrom } from '../../storage/db/types.js';
|
import type {
|
||||||
|
Source,
|
||||||
|
ContentItem,
|
||||||
|
TweetMediaItem,
|
||||||
|
QuotedTweet,
|
||||||
|
TelegramMediaRef,
|
||||||
|
TelegramForwardedFrom
|
||||||
|
} 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 +17,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;
|
||||||
|
};
|
||||||
/** Set by the Telegram adapter only — carries the channel/message info through to ContentItem.telegramMessage. Media is unresolved refs; publish.ts resolves them per the admin's configured telegramMediaMode. */
|
/** Set by the Telegram adapter only — carries the channel/message info through to ContentItem.telegramMessage. Media is unresolved refs; publish.ts resolves them per the admin's configured telegramMediaMode. */
|
||||||
telegramMessage?: {
|
telegramMessage?: {
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
|||||||
@@ -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 } 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: cleanHtml(beforeFooter),
|
||||||
|
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,22 @@ export const nitterAdapter: SourceAdapter = {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// dc:creator is reliably the author of this item's own tweet text (rss-parser
|
// dc:creator (rss-parser maps it to item.creator) is reliably the author of the
|
||||||
// maps it to item.creator) — for a retweet, that's the original tweet's author,
|
// tweet actually being shown — confirmed against a real "RT by @X: ..." sample,
|
||||||
// not whichever list member's retweet surfaced it in this feed.
|
// where dc:creator was the ORIGINAL author, not the retweeter (the retweeter's
|
||||||
const handle = (item.creator ?? '').replace(/^@/, '') || 'unknown';
|
// handle only ever appears in the title's "RT by @X:" prefix, extracted below).
|
||||||
|
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(handle, tweetId);
|
const enrichment = await fetchFxTwitter(rssHandle, tweetId);
|
||||||
|
|
||||||
const authorName = enrichment?.author?.name ?? handle;
|
const authorName = enrichment?.author?.name ?? rssHandle;
|
||||||
|
const handle = enrichment?.author?.screen_name ?? rssHandle;
|
||||||
const avatarUrl = enrichment?.author?.avatar_url ?? null;
|
const avatarUrl = enrichment?.author?.avatar_url ?? null;
|
||||||
const text = enrichment?.text ?? ownHtml;
|
const text = enrichment?.text ?? ownHtml;
|
||||||
// Enrichment failed (or came back with no media) — fall back to the RSS
|
// Enrichment failed (or came back with no media) — fall back to the RSS
|
||||||
@@ -146,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 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ 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, TelegramMediaItem, TelegramMediaRef } from '../storage/db/types.js';
|
import type {
|
||||||
|
GlobalSettings,
|
||||||
|
MergedArticle,
|
||||||
|
ContentItem,
|
||||||
|
TweetMediaItem,
|
||||||
|
QuotedTweet,
|
||||||
|
TelegramMediaItem,
|
||||||
|
TelegramMediaRef
|
||||||
|
} from '../storage/db/types.js';
|
||||||
|
|
||||||
const FOLLOW_UP_LOOKBACK_DAYS = 3;
|
const FOLLOW_UP_LOOKBACK_DAYS = 3;
|
||||||
|
|
||||||
@@ -205,6 +213,16 @@ async function resolveTelegramAvatarUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
@@ -242,12 +260,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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
|
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
|
||||||
export type TelegramMediaItem = TweetMediaItem;
|
export type TelegramMediaItem = TweetMediaItem;
|
||||||
|
|
||||||
@@ -69,7 +82,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;
|
||||||
/** Telegram-sourced items only — null for everything else. Media is unresolved refs (see TelegramMediaRef); publish.ts resolves them (and the channel avatar) per the admin's configured media mode. */
|
/** Telegram-sourced items only — null for everything else. Media is unresolved refs (see TelegramMediaRef); publish.ts resolves them (and the channel avatar) per the admin's configured media mode. */
|
||||||
telegramMessage: {
|
telegramMessage: {
|
||||||
channelName: string;
|
channelName: string;
|
||||||
@@ -95,7 +118,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;
|
||||||
/** Telegram-sourced articles only — the embed card's channel info and attached media (see TelegramCard.svelte). Never set alongside video. */
|
/** Telegram-sourced articles only — the embed card's channel info and attached media (see TelegramCard.svelte). Never set alongside video. */
|
||||||
telegramMessage: {
|
telegramMessage: {
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
|||||||
@@ -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,57 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
.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,6 +17,15 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
|
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
|
||||||
export type TelegramMediaItem = TweetMediaItem;
|
export type TelegramMediaItem = TweetMediaItem;
|
||||||
|
|
||||||
@@ -32,7 +41,15 @@ export interface MergedArticle {
|
|||||||
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;
|
||||||
telegramMessage: {
|
telegramMessage: {
|
||||||
channelName: string;
|
channelName: string;
|
||||||
channelUsername: string;
|
channelUsername: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user