From 206210969236e513ec57b897b41a15d642695cbc Mon Sep 17 00:00:00 2001 From: dangered wolf Date: Mon, 29 Apr 2024 21:24:42 -0400 Subject: [PATCH] Add community notes, author actions --- src/providers/twitter/processor.ts | 8 +++ src/render/instantview.ts | 94 ++++++++++++++++++++++++++---- src/types/types.d.ts | 6 ++ src/types/vendor/twitter.d.ts | 22 ++++++- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/src/providers/twitter/processor.ts b/src/providers/twitter/processor.ts index 0411dfd..9db4f8d 100644 --- a/src/providers/twitter/processor.ts +++ b/src/providers/twitter/processor.ts @@ -127,6 +127,14 @@ export const buildAPITwitterStatus = async ( 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 ?? [] + }; + } + if (status.legacy.lang !== 'unk') { apiStatus.lang = status.legacy.lang; } else { diff --git a/src/render/instantview.ts b/src/render/instantview.ts index 70f4510..721ebfa 100644 --- a/src/render/instantview.ts +++ b/src/render/instantview.ts @@ -4,6 +4,12 @@ import { getSocialTextIV } from '../helpers/socialproof'; import { sanitizeText } from '../helpers/utils'; import { Strings } from '../strings'; +enum AuthorActionType { + Reply = 'Reply', + Original = 'Original', + FollowUp = 'FollowUp' +} + const populateUserLinks = (status: APIStatus, text: string): string => { /* 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 => { @@ -117,8 +123,10 @@ const truncateSocialCount = (count: number): string => { } }; -const generateInlineAuthorHeader = (status: APIStatus, author: APIUser): string => { - return `Reply from ${author.name} (@${author.screen_name}):`; +const generateInlineAuthorHeader = (status: APIStatus, author: APIUser, authorActionType: AuthorActionType | null): string => { + return `

{AuthorAction} from ${author.name} (@${author.screen_name}):

`.format({ + AuthorAction: authorActionType === AuthorActionType.Reply ? 'Reply' : authorActionType === AuthorActionType.Original ? 'Original' : 'Follow-up' + }); } @@ -136,7 +144,7 @@ const generateStatusFooter = (status: APIStatus, isQuote = false, author: APIUse `.format({ socialText: getSocialTextIV(status as APITwitterStatus) || '', viewOriginal: !isQuote - ? `View original post` + ? `View full thread` : notApplicableComment, aboutSection: isQuote ? '' @@ -166,7 +174,48 @@ const generateStatusFooter = (status: APIStatus, isQuote = false, author: APIUse }); }; -const generateStatus = (status: APIStatus, author: APIUser, isQuote = false, differentAuthor = false): string => { +const generateCommunityNote = (status: APITwitterStatus): string => { + if (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 += `${note.text.substring(fromIndex, toIndex)} `; + + lastToIndex = toIndex; + }); + + // Add the remaining text after the last link + result = ` + + + + + + +
Readers added context they thought people might want to know
${result}
`; + + return result; + } + return ''; +} + +const generateStatus = (status: APIStatus, author: APIUser, isQuote = false, authorActionType: AuthorActionType | null): string => { let text = paragraphify(sanitizeText(status.text), isQuote); text = htmlifyLinks(text); text = htmlifyHashtags(text); @@ -181,11 +230,13 @@ const generateStatus = (status: APIStatus, author: APIUser, isQuote = false, dif ${translatedText ? translatedText : notApplicableComment} - ${differentAuthor ? generateInlineAuthorHeader(status, author) : ''} + ${authorActionType ? generateInlineAuthorHeader(status, author, authorActionType) : ''} ${text} + + ${generateCommunityNote(status as APITwitterStatus)} - ${!isQuote && status.quote ? generateStatus(status.quote, author, true) : notApplicableComment} + ${!isQuote && status.quote ? generateStatus(status.quote, author, true, null) : notApplicableComment} `.format({ quoteHeader: isQuote ? `

Quoting ${author.name} (@${author.screen_name})

` @@ -199,6 +250,7 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc const instructions: ResponseInstructions = { addHeaders: [] }; let previousThreadPieceAuthor: string | null = null; + let originalAuthor: string | null = null; if (!status) { throw new Error('Status is undefined'); @@ -228,20 +280,42 @@ export const renderInstantView = (properties: RenderProperties): ResponseInstruc flags?.archive ? `${Constants.BRANDING_NAME} archive` : 'If you can see this, your browser is doing something weird with your user agent.' - } View original post + } View full thread
- View original + View full thread

${status.author.name} (@${status.author.screen_name})

${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, differentAuthor) + return generateStatus(status, status.author ?? thread?.author, false, authorAction) }).join('')} ${generateStatusFooter(status, false, thread?.author ?? status.author)} -
${`View original post`} +
${`View full thread`}
`; return instructions; diff --git a/src/types/types.d.ts b/src/types/types.d.ts index b97c382..6a9c2d4 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -148,11 +148,17 @@ interface APIStatus { embed_card: 'tweet' | 'summary' | 'summary_large_image' | 'player'; } +interface APITwitterCommunityNote { + text: string; + entities: BirdwatchEntity[]; +} + interface APITwitterStatus extends APIStatus { views?: number | null; translation?: APITranslate; is_note_tweet: boolean; + community_note: APITwitterCommunityNote | null; } interface APIUser { diff --git a/src/types/vendor/twitter.d.ts b/src/types/vendor/twitter.d.ts index 16b35b0..6d3cbee 100644 --- a/src/types/vendor/twitter.d.ts +++ b/src/types/vendor/twitter.d.ts @@ -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 = { // Workaround result: GraphQLTwitterStatus; __typename: 'Tweet' | 'TweetWithVisibilityResults' | 'TweetUnavailable'; reason: string; // used for errors 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: { user_results: { result: GraphQLUser;