Merge pull request #778 from FixTweet/instant-view-threads

Telegram Instant View enhancements: Threads, community notes, polls
This commit is contained in:
dangered wolf 2024-04-29 23:22:04 -04:00 committed by GitHub
commit 7aac896da2
Signed by: DevComp
GPG key ID: B5690EEEBB952194
13 changed files with 305 additions and 69 deletions

View file

@ -3,6 +3,7 @@ STANDARD_DOMAIN_LIST = "fxtwitter.com,fixupx.com,twittpr.com"
DIRECT_MEDIA_DOMAINS = "d.fxtwitter.com,dl.fxtwitter.com,d.twittpr.com,dl.twittpr.com,d.fixupx.com,d.xfixup.com,dl.fixupx.com,dl.xfixup.com" DIRECT_MEDIA_DOMAINS = "d.fxtwitter.com,dl.fxtwitter.com,d.twittpr.com,dl.twittpr.com,d.fixupx.com,d.xfixup.com,dl.fixupx.com,dl.xfixup.com"
TEXT_ONLY_DOMAINS = "t.fxtwitter.com,t.twittpr.com,t.fixupx.com" TEXT_ONLY_DOMAINS = "t.fxtwitter.com,t.twittpr.com,t.fixupx.com"
INSTANT_VIEW_DOMAINS = "i.fxtwitter.com,i.twittpr.com,i.fixupx.com" INSTANT_VIEW_DOMAINS = "i.fxtwitter.com,i.twittpr.com,i.fixupx.com"
INSTANT_VIEW_THREADS_DOMAINS = "u.fxtwitter.com,u.twittpr.com,u.fixupx.com"
GALLERY_DOMAINS = "g.fxtwitter.com,g.twittpr.com,g.fixupx.com" GALLERY_DOMAINS = "g.fxtwitter.com,g.twittpr.com,g.fixupx.com"
NATIVE_MULTI_IMAGE_DOMAINS = "m.fxtwitter.com,m.twittpr.com,m.fixupx.com" NATIVE_MULTI_IMAGE_DOMAINS = "m.fxtwitter.com,m.twittpr.com,m.fixupx.com"
MOSAIC_DOMAIN_LIST = "mosaic.fxtwitter.com" MOSAIC_DOMAIN_LIST = "mosaic.fxtwitter.com"

View file

@ -37,6 +37,7 @@ let envVariables = [
'DIRECT_MEDIA_DOMAINS', 'DIRECT_MEDIA_DOMAINS',
'TEXT_ONLY_DOMAINS', 'TEXT_ONLY_DOMAINS',
'INSTANT_VIEW_DOMAINS', 'INSTANT_VIEW_DOMAINS',
'INSTANT_VIEW_THREADS_DOMAINS',
'GALLERY_DOMAINS', 'GALLERY_DOMAINS',
'NATIVE_MULTI_IMAGE_DOMAINS', 'NATIVE_MULTI_IMAGE_DOMAINS',
'HOST_URL', 'HOST_URL',
@ -56,14 +57,10 @@ for (let envVar of envVariables) {
defines['RELEASE_NAME'] = `"${releaseName}"`; defines['RELEASE_NAME'] = `"${releaseName}"`;
await esbuild.build({ const plugins = [];
entryPoints: ['src/worker.ts'],
sourcemap: 'external', if (process.env.SENTRY_DSN) {
outdir: 'dist', plugins.push(
minify: true,
bundle: true,
format: 'esm',
plugins: [
sentryEsbuildPlugin({ sentryEsbuildPlugin({
org: process.env.SENTRY_ORG, org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT, project: process.env.SENTRY_PROJECT,
@ -84,7 +81,16 @@ await esbuild.build({
// https://sentry.io/orgredirect/organizations/:orgslug/settings/auth-tokens/ // https://sentry.io/orgredirect/organizations/:orgslug/settings/auth-tokens/
authToken: process.env.SENTRY_AUTH_TOKEN authToken: process.env.SENTRY_AUTH_TOKEN
}) })
], );
}
await esbuild.build({
entryPoints: ['src/worker.ts'],
sourcemap: 'external',
outdir: 'dist',
minify: true,
bundle: true,
format: 'esm',
plugins: plugins,
define: defines define: defines
}); });

View file

@ -7,6 +7,7 @@
"BRANDING_NAME": "FxTwitter / FixupX", "BRANDING_NAME": "FxTwitter / FixupX",
"TEXT_ONLY_DOMAINS": "t.fxtwitter.com,t.twittpr.com,t.fixupx.com", "TEXT_ONLY_DOMAINS": "t.fxtwitter.com,t.twittpr.com,t.fixupx.com",
"INSTANT_VIEW_DOMAINS": "i.fxtwitter.com,i.twittpr.com,i.fixupx.com", "INSTANT_VIEW_DOMAINS": "i.fxtwitter.com,i.twittpr.com,i.fixupx.com",
"INSTANT_VIEW_THREADS_DOMAINS": "u.fxtwitter.com,u.twittpr.com,u.fixupx.com",
"GALLERY_DOMAINS": "g.fxtwitter.com,g.twittpr.com,g.fixupx.com", "GALLERY_DOMAINS": "g.fxtwitter.com,g.twittpr.com,g.fixupx.com",
"NATIVE_MULTI_IMAGE_DOMAINS": "m.fxtwitter.com,m.twittpr.com,m.fixupx.com", "NATIVE_MULTI_IMAGE_DOMAINS": "m.fxtwitter.com,m.twittpr.com,m.fixupx.com",
"STANDARD_DOMAIN_LIST": "fxtwitter.com,fixupx.com,twittpr.com", "STANDARD_DOMAIN_LIST": "fxtwitter.com,fixupx.com,twittpr.com",
@ -17,6 +18,7 @@
"REDIRECT_URL": "https://github.com/FixTweet/FxTwitter", "REDIRECT_URL": "https://github.com/FixTweet/FxTwitter",
"EMBED_URL": "https://github.com/FixTweet/FxTwitter", "EMBED_URL": "https://github.com/FixTweet/FxTwitter",
"RELEASE_NAME": "fixtweet-test", "RELEASE_NAME": "fixtweet-test",
"GIF_TRANSCODE_DOMAIN": "gif.fxtwitter.com",
"SENTRY_DSN": null "SENTRY_DSN": null
}, },
"testRegex": "/test/.*\\.test\\.ts$", "testRegex": "/test/.*\\.test\\.ts$",

View file

@ -5,6 +5,7 @@ export const Constants = {
DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','), DIRECT_MEDIA_DOMAINS: DIRECT_MEDIA_DOMAINS.split(','),
TEXT_ONLY_DOMAINS: TEXT_ONLY_DOMAINS.split(','), TEXT_ONLY_DOMAINS: TEXT_ONLY_DOMAINS.split(','),
INSTANT_VIEW_DOMAINS: INSTANT_VIEW_DOMAINS.split(','), INSTANT_VIEW_DOMAINS: INSTANT_VIEW_DOMAINS.split(','),
INSTANT_VIEW_THREADS_DOMAINS: INSTANT_VIEW_THREADS_DOMAINS.split(','),
GALLERY_DOMAINS: GALLERY_DOMAINS.split(','), GALLERY_DOMAINS: GALLERY_DOMAINS.split(','),
NATIVE_MULTI_IMAGE_DOMAINS: NATIVE_MULTI_IMAGE_DOMAINS.split(','), NATIVE_MULTI_IMAGE_DOMAINS: NATIVE_MULTI_IMAGE_DOMAINS.split(','),
MOSAIC_DOMAIN_LIST: MOSAIC_DOMAIN_LIST.split(','), MOSAIC_DOMAIN_LIST: MOSAIC_DOMAIN_LIST.split(','),

View file

@ -36,9 +36,12 @@ export const handleStatus = async (
let fetchWithThreads = false; let fetchWithThreads = false;
/* TODO: Enable actually pulling threads once we can actually do something with them */ if (
if (c.req.header('user-agent')?.includes('Telegram') && !flags?.direct) { c.req.header('user-agent')?.includes('Telegram') &&
fetchWithThreads = false; !flags?.direct &&
flags.instantViewUnrollThreads
) {
fetchWithThreads = true;
} }
const thread = await constructTwitterThread( const thread = await constructTwitterThread(
@ -111,6 +114,7 @@ export const handleStatus = async (
status.is_note_tweet || status.is_note_tweet ||
status.quote || status.quote ||
status.translation || status.translation ||
status.community_note ||
flags?.forceInstantView); flags?.forceInstantView);
/* Force enable IV for archivers */ /* Force enable IV for archivers */
@ -193,6 +197,7 @@ export const handleStatus = async (
try { try {
const instructions = renderInstantView({ const instructions = renderInstantView({
status: status, status: status,
thread: thread,
text: newText, text: newText,
flags: flags flags: flags
}); });

View file

@ -1,4 +1,5 @@
export enum Experiment { export enum Experiment {
IV_FORCE_THREAD_UNROLL = 'IV_FORCE_THREAD_UNROLL',
ELONGATOR_BY_DEFAULT = 'ELONGATOR_BY_DEFAULT', ELONGATOR_BY_DEFAULT = 'ELONGATOR_BY_DEFAULT',
ELONGATOR_PROFILE_API = 'ELONGATOR_PROFILE_API', ELONGATOR_PROFILE_API = 'ELONGATOR_PROFILE_API',
TWEET_DETAIL_API = 'TWEET_DETAIL_API', TWEET_DETAIL_API = 'TWEET_DETAIL_API',
@ -13,6 +14,11 @@ type ExperimentConfig = {
}; };
const Experiments: { [key in Experiment]: ExperimentConfig } = { const Experiments: { [key in Experiment]: ExperimentConfig } = {
[Experiment.IV_FORCE_THREAD_UNROLL]: {
name: 'IV force thread unroll',
description: 'Force thread unroll for IVs',
percentage: 0.1
},
[Experiment.ELONGATOR_BY_DEFAULT]: { [Experiment.ELONGATOR_BY_DEFAULT]: {
name: 'Elongator by default', name: 'Elongator by default',
description: 'Enable Elongator by default (guest token lockout bypass)', description: 'Enable Elongator by default (guest token lockout bypass)',

View file

@ -90,14 +90,16 @@ export const fetchTweetDetail = async (
useElongator, useElongator,
(_conversation: unknown) => { (_conversation: unknown) => {
const conversation = _conversation as TweetDetailResult; const conversation = _conversation as TweetDetailResult;
const tweet = findStatusInBucket( const response = processResponse(
status, conversation?.data?.threaded_conversation_with_injections_v2?.instructions
processResponse(conversation?.data?.threaded_conversation_with_injections_v2?.instructions)
); );
const tweet = findStatusInBucket(status, response);
if (tweet && isGraphQLTwitterStatus(tweet)) { if (tweet && isGraphQLTwitterStatus(tweet)) {
return true; return true;
} }
console.log('invalid graphql tweet', conversation); console.log('invalid graphql tweet', tweet);
console.log('finding status', status);
console.log('from response', JSON.stringify(response));
return Array.isArray(conversation?.errors); return Array.isArray(conversation?.errors);
}, },
@ -190,6 +192,7 @@ export const fetchByRestId = async (
const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucket => { const processResponse = (instructions: ThreadInstruction[]): GraphQLProcessBucket => {
const bucket: GraphQLProcessBucket = { const bucket: GraphQLProcessBucket = {
statuses: [], statuses: [],
allStatuses: [],
cursors: [] cursors: []
}; };
instructions?.forEach?.(instruction => { instructions?.forEach?.(instruction => {
@ -264,12 +267,18 @@ const findNextStatus = (id: string, bucket: GraphQLProcessBucket): number => {
}; };
const findPreviousStatus = (id: string, bucket: GraphQLProcessBucket): number => { const findPreviousStatus = (id: string, bucket: GraphQLProcessBucket): number => {
const status = bucket.statuses.find(status => (status.rest_id ?? status.legacy?.id_str) === id); const status = bucket.allStatuses.find(
status => (status.rest_id ?? status.legacy?.id_str) === id
);
if (!status) { if (!status) {
console.log('uhhh, we could not even find that tweet, dunno how that happened'); console.log('uhhh, we could not even find that tweet, dunno how that happened');
return -1; return -1;
} }
return bucket.statuses.findIndex( if ((status.rest_id ?? status.legacy?.id_str) === status.legacy?.in_reply_to_status_id_str) {
console.log('Tweet does not have a parent');
return 0;
}
return bucket.allStatuses.findIndex(
_status => _status =>
(_status.rest_id ?? _status.legacy?.id_str) === status.legacy?.in_reply_to_status_id_str (_status.rest_id ?? _status.legacy?.id_str) === status.legacy?.in_reply_to_status_id_str
); );
@ -308,6 +317,8 @@ export const constructTwitterThread = async (
let response: TweetDetailResult | TweetResultsByRestIdResult | null = null; let response: TweetDetailResult | TweetResultsByRestIdResult | null = null;
let status: APITwitterStatus; let status: APITwitterStatus;
console.log('env', c.env);
/* We can use TweetDetail on elongator accounts to increase per-account rate limit. /* We can use TweetDetail on elongator accounts to increase per-account rate limit.
We also use TweetDetail to process threads (WIP) We also use TweetDetail to process threads (WIP)
@ -341,7 +352,7 @@ export const constructTwitterThread = async (
return { status: null, thread: null, author: null, code: 404 }; return { status: null, thread: null, author: null, code: 404 };
} }
const buildStatus = await buildAPITwitterStatus(c, result, language, false, legacyAPI); const buildStatus = await buildAPITwitterStatus(c, result, language, null, legacyAPI);
if ((buildStatus as FetchResults)?.status === 401) { if ((buildStatus as FetchResults)?.status === 401) {
writeDataPoint(c, language, null, '401'); writeDataPoint(c, language, null, '401');
@ -372,7 +383,7 @@ export const constructTwitterThread = async (
c, c,
originalStatus, originalStatus,
undefined, undefined,
false, null,
legacyAPI legacyAPI
)) as APITwitterStatus; )) as APITwitterStatus;
@ -390,6 +401,7 @@ export const constructTwitterThread = async (
} }
const threadStatuses = [originalStatus]; const threadStatuses = [originalStatus];
bucket.allStatuses = bucket.statuses;
bucket.statuses = filterBucketStatuses(bucket.statuses, originalStatus); bucket.statuses = filterBucketStatuses(bucket.statuses, originalStatus);
let currentId = id; let currentId = id;
@ -465,7 +477,7 @@ export const constructTwitterThread = async (
while (findPreviousStatus(currentId, bucket) !== -1) { while (findPreviousStatus(currentId, bucket) !== -1) {
const index = findPreviousStatus(currentId, bucket); const index = findPreviousStatus(currentId, bucket);
const status = bucket.statuses[index]; const status = bucket.allStatuses[index];
const newCurrentId = status.rest_id ?? status.legacy?.id_str; const newCurrentId = status.rest_id ?? status.legacy?.id_str;
console.log( console.log(
@ -533,10 +545,30 @@ export const constructTwitterThread = async (
code: 200 code: 200
}; };
threadStatuses.forEach(async status => { await Promise.all(
socialThread.thread?.push( threadStatuses.map(async status => {
(await buildAPITwitterStatus(c, status, undefined, true, false)) as APITwitterStatus const builtStatus = (await buildAPITwitterStatus(
c,
status,
undefined,
author,
false
)) as APITwitterStatus;
socialThread.thread?.push(builtStatus);
})
); );
// Sort socialThread.thread by id converted to bigint
socialThread.thread?.sort((a, b) => {
const aId = BigInt(a.id);
const bId = BigInt(b.id);
if (aId < bId) {
return -1;
}
if (aId > bId) {
return 1;
}
return 0;
}); });
return socialThread; return socialThread;

View file

@ -12,7 +12,7 @@ export const buildAPITwitterStatus = async (
c: Context, c: Context,
status: GraphQLTwitterStatus, status: GraphQLTwitterStatus,
language: string | undefined, language: string | undefined,
threadPiece = false, threadAuthor: null | APIUser,
legacyAPI = false legacyAPI = false
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITwitterStatus | FetchResults | null> => { ): Promise<APITwitterStatus | FetchResults | null> => {
@ -58,7 +58,7 @@ export const buildAPITwitterStatus = async (
apiStatus.text = unescapeText( apiStatus.text = unescapeText(
linkFixer(status.legacy.entities?.urls, status.legacy.full_text || '') linkFixer(status.legacy.entities?.urls, status.legacy.full_text || '')
); );
if (!threadPiece) { // if (threadAuthor && threadAuthor.id !== apiUser.id) {
apiStatus.author = { apiStatus.author = {
id: apiUser.id, id: apiUser.id,
name: apiUser.name, name: apiUser.name,
@ -77,7 +77,7 @@ export const buildAPITwitterStatus = async (
birthday: apiUser.birthday, birthday: apiUser.birthday,
website: apiUser.website website: apiUser.website
}; };
} // }
apiStatus.replies = status.legacy.reply_count; apiStatus.replies = status.legacy.reply_count;
if (legacyAPI) { if (legacyAPI) {
// @ts-expect-error Use retweets for legacy API // @ts-expect-error Use retweets for legacy API
@ -94,7 +94,9 @@ export const buildAPITwitterStatus = async (
delete apiStatus.author.global_screen_name; delete apiStatus.author.global_screen_name;
} else { } else {
apiStatus.reposts = status.legacy.retweet_count; apiStatus.reposts = status.legacy.retweet_count;
// if ((threadAuthor && threadAuthor.id !== apiUser.id)) {
apiStatus.author.global_screen_name = apiUser.global_screen_name; apiStatus.author.global_screen_name = apiUser.global_screen_name;
// }
} }
apiStatus.likes = status.legacy.favorite_count; apiStatus.likes = status.legacy.favorite_count;
apiStatus.embed_card = 'tweet'; apiStatus.embed_card = 'tweet';
@ -125,6 +127,16 @@ export const buildAPITwitterStatus = async (
apiStatus.is_note_tweet = false; apiStatus.is_note_tweet = false;
} }
if (status.birdwatch_pivot?.subtitle?.text) {
/* We can't automatically replace links because API doesn't give full URLs, only t.co versions :( */
apiStatus.community_note = {
text: unescapeText(status.birdwatch_pivot?.subtitle?.text ?? ''),
entities: status.birdwatch_pivot.subtitle?.entities ?? []
};
} else {
apiStatus.community_note = null;
}
if (status.legacy.lang !== 'unk') { if (status.legacy.lang !== 'unk') {
apiStatus.lang = status.legacy.lang; apiStatus.lang = status.legacy.lang;
} else { } else {
@ -150,7 +162,7 @@ export const buildAPITwitterStatus = async (
/* We found a quote, let's process that too */ /* We found a quote, let's process that too */
const quote = status.quoted_status_result; const quote = status.quoted_status_result;
if (quote) { if (quote) {
const buildQuote = await buildAPITwitterStatus(c, quote, language, threadPiece, legacyAPI); const buildQuote = await buildAPITwitterStatus(c, quote, language, threadAuthor, legacyAPI);
if ((buildQuote as FetchResults).status) { if ((buildQuote as FetchResults).status) {
apiStatus.quote = undefined; apiStatus.quote = undefined;
} else { } else {
@ -197,7 +209,7 @@ export const buildAPITwitterStatus = async (
*/ */
/* Handle photos and mosaic if available */ /* Handle photos and mosaic if available */
if ((apiStatus?.media.photos?.length || 0) > 1 && !threadPiece) { if ((apiStatus?.media.photos?.length || 0) > 1 && !threadAuthor) {
const mosaic = await handleMosaic(apiStatus.media?.photos || [], id); const mosaic = await handleMosaic(apiStatus.media?.photos || [], id);
if (typeof apiStatus.media !== 'undefined' && mosaic !== null) { if (typeof apiStatus.media !== 'undefined' && mosaic !== null) {
apiStatus.media.mosaic = mosaic; apiStatus.media.mosaic = mosaic;

View file

@ -3,6 +3,7 @@ import { Constants } from '../../../constants';
import { getBaseRedirectUrl } from '../router'; import { getBaseRedirectUrl } from '../router';
import { handleStatus } from '../../../embed/status'; import { handleStatus } from '../../../embed/status';
import { Strings } from '../../../strings'; import { Strings } from '../../../strings';
import { Experiment, experimentCheck } from '../../../experiments';
/* Handler for status request */ /* Handler for status request */
export const statusRequest = async (c: Context) => { export const statusRequest = async (c: Context) => {
@ -55,6 +56,13 @@ export const statusRequest = async (c: Context) => {
} else if (Constants.INSTANT_VIEW_DOMAINS.includes(url.hostname)) { } else if (Constants.INSTANT_VIEW_DOMAINS.includes(url.hostname)) {
console.log('Forced instant view request'); console.log('Forced instant view request');
flags.forceInstantView = true; flags.forceInstantView = true;
} else if (
experimentCheck(Experiment.IV_FORCE_THREAD_UNROLL, userAgent.includes('Telegram')) ||
Constants.INSTANT_VIEW_THREADS_DOMAINS.includes(url.hostname)
) {
console.log('Forced unroll instant view');
flags.forceInstantView = true;
flags.instantViewUnrollThreads = true;
} else if (Constants.GALLERY_DOMAINS.includes(url.hostname)) { } else if (Constants.GALLERY_DOMAINS.includes(url.hostname)) {
console.log('Gallery embed request'); console.log('Gallery embed request');
flags.gallery = true; flags.gallery = true;

View file

@ -4,6 +4,12 @@ import { getSocialTextIV } from '../helpers/socialproof';
import { sanitizeText } from '../helpers/utils'; import { sanitizeText } from '../helpers/utils';
import { Strings } from '../strings'; import { Strings } from '../strings';
enum AuthorActionType {
Reply = 'Reply',
Original = 'Original',
FollowUp = 'FollowUp'
}
const populateUserLinks = (status: APIStatus, text: string): string => { const populateUserLinks = (status: APIStatus, text: string): string => {
/* TODO: Maybe we can add username splices to our API so only genuinely valid users are linked? */ /* TODO: Maybe we can add username splices to our API so only genuinely valid users are linked? */
text.match(/@(\w{1,15})/g)?.forEach(match => { text.match(/@(\w{1,15})/g)?.forEach(match => {
@ -16,7 +22,7 @@ const populateUserLinks = (status: APIStatus, text: string): string => {
return text; return text;
}; };
const generateStatusMedia = (status: APIStatus): string => { const generateStatusMedia = (status: APIStatus, author: APIUser): string => {
let media = ''; let media = '';
if (status.media?.all?.length) { if (status.media?.all?.length) {
status.media.all.forEach(mediaItem => { status.media.all.forEach(mediaItem => {
@ -30,10 +36,10 @@ const generateStatusMedia = (status: APIStatus): string => {
}); });
break; break;
case 'video': case 'video':
media += `<video src="${mediaItem.url}" alt="${status.author.name}'s video. Alt text not available."/>`; media += `<video src="${mediaItem.url}" alt="${author.name}'s video. Alt text not available."/>`;
break; break;
case 'gif': case 'gif':
media += `<video src="${mediaItem.url}" alt="${status.author.name}'s gif. Alt text not available."/>`; media += `<video src="${mediaItem.url}" alt="${author.name}'s gif. Alt text not available."/>`;
break; break;
} }
}); });
@ -117,6 +123,23 @@ const truncateSocialCount = (count: number): string => {
} }
}; };
const generateInlineAuthorHeader = (
status: APIStatus,
author: APIUser,
authorActionType: AuthorActionType | null
): string => {
return `<h4><i><a href="${status.url}">{AuthorAction}</a> from <b>${author.name}</b> (<a href="${author.url}">@${author.screen_name}</a>):</i></h4>`.format(
{
AuthorAction:
authorActionType === AuthorActionType.Reply
? 'Reply'
: authorActionType === AuthorActionType.Original
? 'Original'
: 'Follow-up'
}
);
};
const wrapForeignLinks = (url: string) => { const wrapForeignLinks = (url: string) => {
let unwrap = false; let unwrap = false;
const whitelistedDomains = ['twitter.com', 'x.com', 't.me', 'telegram.me']; const whitelistedDomains = ['twitter.com', 'x.com', 't.me', 'telegram.me'];
@ -135,9 +158,7 @@ const wrapForeignLinks = (url: string) => {
: url; : url;
}; };
const generateStatusFooter = (status: APIStatus, isQuote = false): string => { const generateStatusFooter = (status: APIStatus, isQuote = false, author: APIUser): string => {
const { author } = status;
let description = author.description; let description = author.description;
description = htmlifyLinks(description); description = htmlifyLinks(description);
description = htmlifyHashtags(description); description = htmlifyHashtags(description);
@ -150,9 +171,7 @@ const generateStatusFooter = (status: APIStatus, isQuote = false): string => {
{aboutSection} {aboutSection}
`.format({ `.format({
socialText: getSocialTextIV(status as APITwitterStatus) || '', socialText: getSocialTextIV(status as APITwitterStatus) || '',
viewOriginal: !isQuote viewOriginal: !isQuote ? `<a href="${status.url}">View full thread</a>` : notApplicableComment,
? `<a href="${status.url}">View original post</a>`
: notApplicableComment,
aboutSection: isQuote aboutSection: isQuote
? '' ? ''
: `<h2>About author</h2> : `<h2>About author</h2>
@ -181,7 +200,71 @@ const generateStatusFooter = (status: APIStatus, isQuote = false): string => {
}); });
}; };
const generateStatus = (status: APIStatus, isQuote = false): string => { const generatePoll = (poll: APIPoll, language: string): string => {
const intlFormat = Intl.NumberFormat(language ?? 'en');
let str = '';
const barLength = 20;
poll.choices.forEach(choice => {
const bar = '█'.repeat((choice.percentage / 100) * barLength);
// eslint-disable-next-line no-irregular-whitespace
str += `${bar}<br>${choice.label}<br>${intlFormat.format(choice.count)} votes, ${intlFormat.format(choice.percentage)}%<br>`;
});
/* Finally, add the footer of the poll with # of votes and time left */
str += `<br>${intlFormat.format(poll.total_votes)} votes · ${poll.time_left_en}`;
return str;
};
const generateCommunityNote = (status: APITwitterStatus): string => {
if (status.community_note) {
console.log('community_note', status.community_note);
const note = status.community_note;
const entities = note.entities;
entities.sort((a, b) => a.fromIndex - b.fromIndex); // sort entities by fromIndex
let lastToIndex = 0;
let result = '';
entities.forEach(entity => {
if (entity?.ref?.type !== 'TimelineUrl') {
return;
}
const fromIndex = entity.fromIndex;
const toIndex = entity.toIndex;
const url = entity.ref.url;
// Add the text before the link
result += note.text.substring(lastToIndex, fromIndex);
// Add the link
result += `<a href="${url}">${note.text.substring(fromIndex, toIndex)}</a>`;
lastToIndex = toIndex;
});
// Add the remaining text after the last link
result = `<table>
<thead>
<th><b>Readers added context they thought people might want to know</b></th>
</thead>
<tbody>
<th>${result.replace(/\n/g, '\n<br>')}</th>
</tbody>
</table>`;
return result;
}
return '';
};
const generateStatus = (
status: APIStatus,
author: APIUser,
isQuote = false,
authorActionType: AuthorActionType | null
): string => {
let text = paragraphify(sanitizeText(status.text), isQuote); let text = paragraphify(sanitizeText(status.text), isQuote);
text = htmlifyLinks(text); text = htmlifyLinks(text);
text = htmlifyHashtags(text); text = htmlifyHashtags(text);
@ -192,26 +275,38 @@ const generateStatus = (status: APIStatus, isQuote = false): string => {
return `<!-- Telegram Instant View --> return `<!-- Telegram Instant View -->
{quoteHeader} {quoteHeader}
<!-- Embed media --> <!-- Embed media -->
${generateStatusMedia(status)} ${generateStatusMedia(status, author)}
<!-- Translated text (if applicable) --> <!-- Translated text (if applicable) -->
${translatedText ? translatedText : notApplicableComment} ${translatedText ? translatedText : notApplicableComment}
<!-- Inline author (if applicable) -->
${authorActionType ? generateInlineAuthorHeader(status, author, authorActionType) : ''}
<!-- Embed Status text --> <!-- Embed Status text -->
${text} ${text}
<!-- Embed Community Note -->
${generateCommunityNote(status as APITwitterStatus)}
<!-- Embed poll -->
${status.poll ? generatePoll(status.poll, status.lang ?? 'en') : notApplicableComment}
<!-- Embedded quote status --> <!-- Embedded quote status -->
${!isQuote && status.quote ? generateStatus(status.quote, true) : notApplicableComment} ${!isQuote && status.quote ? generateStatus(status.quote, author, true, null) : notApplicableComment}
${!isQuote ? generateStatusFooter(status) : ''}
<br>${!isQuote ? `<a href="${status.url}">View original post</a>` : notApplicableComment}
`.format({ `.format({
quoteHeader: isQuote quoteHeader: isQuote
? `<h4><a href="${status.url}">Quoting</a> ${status.author.name} (<a href="${Constants.TWITTER_ROOT}/${status.author.screen_name}">@${status.author.screen_name}</a>)</h4>` ? `<h4><a href="${status.url}">Quoting</a> ${author.name} (<a href="${Constants.TWITTER_ROOT}/${author.screen_name}">@${author.screen_name}</a>)</h4>`
: '' : ''
}); });
}; };
export const renderInstantView = (properties: RenderProperties): ResponseInstructions => { export const renderInstantView = (properties: RenderProperties): ResponseInstructions => {
console.log('Generating Instant View...'); console.log('Generating Instant View...');
const { status, flags } = properties; const { status, thread, flags } = properties;
const instructions: ResponseInstructions = { addHeaders: [] }; const instructions: ResponseInstructions = { addHeaders: [] };
let previousThreadPieceAuthor: string | null = null;
let originalAuthor: string | null = null;
if (!status) {
throw new Error('Status is undefined');
}
/* Use ISO date for Medium template */ /* Use ISO date for Medium template */
const statusDate = new Date(status.created_at).toISOString(); const statusDate = new Date(status.created_at).toISOString();
@ -236,13 +331,51 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc
flags?.archive flags?.archive
? `${Constants.BRANDING_NAME} archive` ? `${Constants.BRANDING_NAME} archive`
: 'If you can see this, your browser is doing something weird with your user agent.' : 'If you can see this, your browser is doing something weird with your user agent.'
} <a href="${status.url}">View original post</a> } <a href="${status.url}">View full thread</a>
</section> </section>
<article> <article>
<sub><a href="${status.url}">View original</a></sub> <sub><a href="${status.url}">View full thread</a></sub>
<h1>${status.author.name} (@${status.author.screen_name})</h1> <h1>${status.author.name} (@${status.author.screen_name})</h1>
${generateStatus(status)} ${thread?.thread
?.map(status => {
console.log('previousThreadPieceAuthor', previousThreadPieceAuthor);
if (originalAuthor === null) {
originalAuthor = status.author?.id;
}
const differentAuthor =
thread?.author?.id !== status.author?.id ||
(previousThreadPieceAuthor !== null && previousThreadPieceAuthor !== status.author?.id);
const isOriginal =
thread?.author?.id !== status.author?.id && previousThreadPieceAuthor === null;
const isFollowup =
thread?.author?.id === status.author?.id &&
previousThreadPieceAuthor !== null &&
previousThreadPieceAuthor !== thread?.author?.id &&
originalAuthor === status.author?.id;
console.log('differentAuthor', differentAuthor);
console.log('isOriginal', isOriginal);
console.log('isFollowup', isFollowup);
let authorAction = null;
if (differentAuthor) {
if (isFollowup) {
authorAction = AuthorActionType.FollowUp;
} else if (isOriginal) {
authorAction = AuthorActionType.Original;
} else if (previousThreadPieceAuthor !== status.author?.id) {
authorAction = AuthorActionType.Reply;
}
}
previousThreadPieceAuthor = status.author?.id;
return generateStatus(status, status.author ?? thread?.author, false, authorAction);
})
.join('')}
${generateStatusFooter(status, false, thread?.author ?? status.author)}
<br>${`<a href="${status.url}">View full thread</a>`}
</article>`; </article>`;
return instructions; return instructions;

1
src/types/env.d.ts vendored
View file

@ -3,6 +3,7 @@ declare const STANDARD_DOMAIN_LIST: string;
declare const DIRECT_MEDIA_DOMAINS: string; declare const DIRECT_MEDIA_DOMAINS: string;
declare const TEXT_ONLY_DOMAINS: string; declare const TEXT_ONLY_DOMAINS: string;
declare const INSTANT_VIEW_DOMAINS: string; declare const INSTANT_VIEW_DOMAINS: string;
declare const INSTANT_VIEW_THREADS_DOMAINS: string;
declare const GALLERY_DOMAINS: string; declare const GALLERY_DOMAINS: string;
declare const NATIVE_MULTI_IMAGE_DOMAINS: string; declare const NATIVE_MULTI_IMAGE_DOMAINS: string;
declare const HOST_URL: string; declare const HOST_URL: string;

View file

@ -8,6 +8,7 @@ type InputFlags = {
textOnly?: boolean; textOnly?: boolean;
isXDomain?: boolean; isXDomain?: boolean;
forceInstantView?: boolean; forceInstantView?: boolean;
instantViewUnrollThreads?: boolean;
archive?: boolean; archive?: boolean;
gallery?: boolean; gallery?: boolean;
nativeMultiImage?: boolean; nativeMultiImage?: boolean;
@ -29,6 +30,7 @@ interface ResponseInstructions {
interface RenderProperties { interface RenderProperties {
status: APITwitterStatus; status: APITwitterStatus;
thread?: SocialThread;
siteText?: string; siteText?: string;
authorText?: string; authorText?: string;
engagementText?: string; engagementText?: string;
@ -148,11 +150,17 @@ interface APIStatus {
embed_card: 'tweet' | 'summary' | 'summary_large_image' | 'player'; embed_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
} }
interface APITwitterCommunityNote {
text: string;
entities: BirdwatchEntity[];
}
interface APITwitterStatus extends APIStatus { interface APITwitterStatus extends APIStatus {
views?: number | null; views?: number | null;
translation?: APITranslate; translation?: APITranslate;
is_note_tweet: boolean; is_note_tweet: boolean;
community_note: APITwitterCommunityNote | null;
} }
interface APIUser { interface APIUser {

View file

@ -351,13 +351,33 @@ type GraphQLTwitterStatusLegacy = {
}; };
}; };
type BirdwatchEntity = {
fromIndex: number; // 119
toIndex: number; // 154
ref: {
type: 'TimelineUrl';
url: string; // https://t.co/jxvVatCVCz
urlType: 'ExternalUrl';
};
};
type GraphQLTwitterStatus = { type GraphQLTwitterStatus = {
// Workaround // Workaround
result: GraphQLTwitterStatus; result: GraphQLTwitterStatus;
__typename: 'Tweet' | 'TweetWithVisibilityResults' | 'TweetUnavailable'; __typename: 'Tweet' | 'TweetWithVisibilityResults' | 'TweetUnavailable';
reason: string; // used for errors reason: string; // used for errors
rest_id: string; // "1674824189176590336", rest_id: string; // "1674824189176590336",
has_birdwatch_notes: false; has_birdwatch_notes: boolean;
birdwatch_pivot: {
destinationUrl: string; // https://twitter.com/i/birdwatch/n/1784594925926973714
note: {
rest_id: string; // 1784594925926973714
};
subtitle: {
text: string; // "This screenshot is from Sonic 1\n\ninfo.sonicretro.org/Sonic_the_Hedg…"
entities: BirdwatchEntity[];
};
};
core: { core: {
user_results: { user_results: {
result: GraphQLUser; result: GraphQLUser;
@ -573,5 +593,6 @@ type TweetStub = {
interface GraphQLProcessBucket { interface GraphQLProcessBucket {
statuses: GraphQLTwitterStatus[]; statuses: GraphQLTwitterStatus[];
allStatuses: GraphQLTwitterStatus[];
cursors: GraphQLTimelineCursor[]; cursors: GraphQLTimelineCursor[];
} }