From a15d1ce16bf9e8227f95bbcf2a8cbcf6dff87857 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 16 Feb 2026 00:52:17 +0100 Subject: [PATCH] Add full support for tweet edit history Fixes #700 --- src/api.nim | 7 ++++ src/consts.nim | 74 +++++++++++++++++++++----------------- src/formatters.nim | 12 ++++--- src/parser.nim | 29 ++++++++++++++- src/routes/status.nim | 22 +++++++++++- src/sass/timeline.scss | 9 ++--- src/sass/tweet/_base.scss | 16 +++++++-- src/sass/tweet/quote.scss | 5 +++ src/sass/tweet/thread.scss | 31 +++++++++++----- src/types.nim | 5 +++ src/views/general.nim | 2 +- src/views/status.nim | 14 ++++++++ src/views/tweet.nim | 28 +++++++++++++-- 13 files changed, 194 insertions(+), 60 deletions(-) diff --git a/src/api.nim b/src/api.nim index acd25f1..ef44bd0 100644 --- a/src/api.nim +++ b/src/api.nim @@ -138,6 +138,13 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) +proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} = + if id.len == 0: return + let + url = apiReq(graphTweetEditHistory, tweetEditHistoryVars % id) + js = await fetch(url) + result = parseGraphEditHistory(js, id) + proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} = let q = genQueryParam(query) if q.len == 0 or q == emptyQuery: diff --git a/src/consts.nim b/src/consts.nim index 6456efc..38fd870 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -19,6 +19,7 @@ const graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" + graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" graphSearchTimeline* = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline" graphListById* = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId" graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug" @@ -29,35 +30,67 @@ const "android_ad_formats_media_component_render_overlay_enabled": false, "android_graphql_skip_api_media_color_palette": false, "android_professional_link_spotlight_display_enabled": false, + "articles_api_enabled": false, + "articles_preview_enabled": true, "blue_business_profile_image_shape_enabled": false, + "c9s_tweet_anatomy_moderator_badge_enabled": true, "commerce_android_shop_module_enabled": false, + "communities_web_enable_tweet_community_results_fetch": true, + "creator_subscriptions_quote_tweet_preview_enabled": false, "creator_subscriptions_subscription_count_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, + "grok_android_analyze_trend_fetch_enabled": false, + "grok_translations_community_note_auto_translation_is_enabled": false, + "grok_translations_community_note_translation_is_enabled": false, + "grok_translations_post_auto_translation_is_enabled": false, + "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, "hidden_profile_likes_enabled": false, "highlights_tweets_tab_ui_enabled": false, + "immersive_video_status_linkable_timestamps": false, "interactive_text_enabled": false, "longform_notetweets_consumption_enabled": true, "longform_notetweets_inline_media_enabled": true, - "longform_notetweets_rich_text_read_enabled": true, "longform_notetweets_richtext_consumption_enabled": true, + "longform_notetweets_rich_text_read_enabled": true, "mobile_app_spotlight_module_enabled": false, + "payments_enabled": false, + "post_ctas_fetch_enabled": true, + "premium_content_api_read_enabled": false, + "profile_label_improvements_pcf_label_in_post_enabled": true, + "profile_label_improvements_pcf_label_in_profile_enabled": false, "responsive_web_edit_tweet_api_enabled": true, "responsive_web_enhance_cards_enabled": false, "responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": true, + "responsive_web_grok_analysis_button_from_backend": true, + "responsive_web_grok_analyze_button_fetch_trends_enabled": false, + "responsive_web_grok_analyze_post_followups_enabled": true, + "responsive_web_grok_annotations_enabled": true, + "responsive_web_grok_community_note_auto_translation_is_enabled": false, + "responsive_web_grok_image_annotation_enabled": true, + "responsive_web_grok_imagine_annotation_enabled": true, + "responsive_web_grok_share_attachment_enabled": true, + "responsive_web_grok_show_grok_translated_post": false, + "responsive_web_jetfuel_frame": true, "responsive_web_media_download_video_enabled": false, + "responsive_web_profile_redirect_enabled": false, "responsive_web_text_conversations_enabled": false, + "responsive_web_twitter_article_notes_tab_enabled": false, "responsive_web_twitter_article_tweet_consumption_enabled": true, - "unified_cards_destination_url_params_enabled": false, "responsive_web_twitter_blue_verified_badge_is_enabled": true, "rweb_lists_timeline_redesign_enabled": true, + "rweb_tipjar_consumption_enabled": true, + "rweb_video_screen_enabled": false, + "rweb_video_timestamps_enabled": false, "spaces_2022_h2_clipping": true, "spaces_2022_h2_spaces_communities": true, "standardized_nudges_misinfo": true, + "subscriptions_feature_can_gift_premium": false, "subscriptions_verification_info_enabled": true, + "subscriptions_verification_info_is_identity_verified_enabled": false, "subscriptions_verification_info_reason_enabled": true, "subscriptions_verification_info_verified_since_enabled": true, "super_follow_badge_privacy_enabled": false, @@ -68,40 +101,10 @@ const "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "tweetypie_unmention_optimization_enabled": false, "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, + "unified_cards_destination_url_params_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, "view_counts_everywhere_api_enabled": true, - "premium_content_api_read_enabled": false, - "communities_web_enable_tweet_community_results_fetch": true, - "responsive_web_jetfuel_frame": true, - "responsive_web_grok_analyze_button_fetch_trends_enabled": false, - "responsive_web_grok_image_annotation_enabled": true, - "responsive_web_grok_imagine_annotation_enabled": true, - "rweb_tipjar_consumption_enabled": true, - "profile_label_improvements_pcf_label_in_post_enabled": true, - "creator_subscriptions_quote_tweet_preview_enabled": false, - "c9s_tweet_anatomy_moderator_badge_enabled": true, - "responsive_web_grok_analyze_post_followups_enabled": true, - "rweb_video_timestamps_enabled": false, - "responsive_web_grok_share_attachment_enabled": true, - "articles_preview_enabled": true, - "immersive_video_status_linkable_timestamps": false, - "articles_api_enabled": false, - "responsive_web_grok_analysis_button_from_backend": true, - "rweb_video_screen_enabled": false, - "payments_enabled": false, - "responsive_web_profile_redirect_enabled": false, - "responsive_web_grok_show_grok_translated_post": false, - "responsive_web_grok_community_note_auto_translation_is_enabled": false, - "profile_label_improvements_pcf_label_in_profile_enabled": false, - "grok_android_analyze_trend_fetch_enabled": false, - "grok_translations_community_note_auto_translation_is_enabled": false, - "grok_translations_post_auto_translation_is_enabled": false, - "grok_translations_community_note_translation_is_enabled": false, - "grok_translations_timeline_user_bio_auto_translation_is_enabled": false, - "subscriptions_feature_can_gift_premium": false, - "responsive_web_twitter_article_notes_tab_enabled": false, - "subscriptions_verification_info_is_identity_verified_enabled": false, "hidden_profile_subscriptions_enabled": false }""".replace(" ", "").replace("\n", "") @@ -128,6 +131,11 @@ const "withVoice": true }""".replace(" ", "").replace("\n", "") + tweetEditHistoryVars* = """{ + "tweetId": "$1", + "withQuickPromoteEligibilityTweetFields": true +}""".replace(" ", "").replace("\n", "") + restIdVars* = """{ "rest_id": "$1", $2 "count": 20 diff --git a/src/formatters.nim b/src/formatters.nim index 077c7c4..5d06535 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -165,13 +165,17 @@ proc getDuration*(video: Video): string = else: return &"{min mod 60}:{sec mod 60:02}" +proc getLink*(id: int64; username="i"; focus=true): string = + var username = username + if username.len == 0: + username = "i" + result = &"/{username}/status/{id}" + if focus: result &= "#m" + proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return var username = tweet.user.username - if username.len == 0: - username = "i" - result = &"/{username}/status/{tweet.id}" - if focus: result &= "#m" + return getLink(tweet.id, username, focus) proc getTwitterLink*(path: string; params: Table[string, string]): string = var diff --git a/src/parser.nim b/src/parser.nim index 0ea91bc..6b8da60 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -435,12 +435,16 @@ proc parseGraphTweet(js: JsonNode): Tweet = else: result.quote = some Tweet(id: js{"legacy", "quoted_status_id_str"}.getId) + with ids, js{"edit_control", "edit_control_initial", "edit_tweet_ids"}: + for id in ids: + result.history.add parseBiggestInt(id.getStr) + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in ? js{"content", "items"}: let entryId = t.getEntryId if "tweet-" in entryId and "promoted" notin entryId: let tweet = t.getTweetResult("item") - if not tweet.isNull: + if tweet.notNull: result.thread.content.add parseGraphTweet(tweet) let tweetDisplayType = select( @@ -516,6 +520,29 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = ) result.replies.bottom = cursorValue.getStr +proc parseGraphEditHistory*(js: JsonNode; tweetId: string): EditHistory = + let instructions = ? js{ + "data", "tweet_result_by_rest_id", "result", + "edit_history_timeline", "timeline", "instructions" + } + if instructions.len == 0: + return + + for i in instructions: + if i.getTypeName == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e.getEntryId + if entryId == "latestTweet": + with item, e{"content", "items"}[0]: + let tweetResult = item.getTweetResult("item") + if tweetResult.notNull: + result.latest = parseGraphTweet(tweetResult) + elif entryId == "staleTweets": + for item in e{"content", "items"}: + let tweetResult = item.getTweetResult("item") + if tweetResult.notNull: + result.history.add parseGraphTweet(tweetResult) + proc extractTweetsFromEntry*(e: JsonNode): seq[Tweet] = with tweetResult, getTweetResult(e): var tweet = parseGraphTweet(tweetResult) diff --git a/src/routes/status.nim b/src/routes/status.nim index 7967f77..7322837 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -64,9 +64,29 @@ proc createStatusRouter*(cfg: Config) = resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, images=images, video=video) + get "/@name/status/@id/history/?": + cond '.' notin @"name" + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + + let edits = await getGraphEditHistory(id) + if edits.latest == nil or edits.latest.id == 0: + resp Http404, showError("Tweet history not found", cfg) + + let + prefs = requestPrefs() + title = "History for " & pageTitle(edits.latest) + ogTitle = "Edit History for " & pageTitle(edits.latest.user) + desc = edits.latest.text + + let html = renderEditHistory(edits, prefs, getPath()) + resp renderMain(html, request, cfg, prefs, title, desc, ogTitle) + get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] - cond @"m" in ["video", "photo", "history"] + cond @"m" in ["video", "photo"] redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/statuses/@id/?": diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index 40882b2..eeb794a 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -4,12 +4,8 @@ @include panel(100%, 600px); } -.timeline { - background-color: var(--bg_panel); - - > div:not(:first-child) { - border-top: 1px solid var(--border_grey); - } +.timeline > div:not(:first-child) { + border-top: 1px solid var(--border_grey); } .timeline-header { @@ -159,4 +155,5 @@ padding: 0.75em; display: flex; position: relative; + background-color: var(--bg_panel); } diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 7f2d931..28590e7 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -80,8 +80,8 @@ } .tweet-published { - margin: 0; - margin-top: 5px; + margin-top: 10px; + margin-bottom: 3px; color: var(--grey); pointer-events: all; } @@ -242,3 +242,15 @@ background-color: var(--bg_hover); } } + +.latest-post-version { + border-bottom: 1px solid var(--dark_grey); + border-top: 1px solid var(--dark_grey); + padding: 01ch 0px; + margin: 1ch 0px; + color: var(--grey); + + a { + pointer-events: all; + } +} diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 5894735..64d7e13 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -35,6 +35,11 @@ margin-top: -6px; } + .quote-latest { + padding: 0px 8px 6px 8px; + color: var(--grey); + } + .replying-to { padding: 0px 8px; margin: unset; diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index d9bc457..c5165d7 100644 --- a/src/sass/tweet/thread.scss +++ b/src/sass/tweet/thread.scss @@ -1,7 +1,8 @@ @import "_variables"; @import "_mixins"; -.conversation { +.conversation, +.edit-history { @include panel(100%, 600px); .show-more { @@ -9,19 +10,36 @@ } } -.main-thread { +.main-thread, +.latest-edit { margin-bottom: 20px; - background-color: var(--bg_panel); +} + +.reply { + margin-bottom: 10px; } .main-tweet, -.replies { +.replies, +.edit-history > div { body.fixed-nav & { padding-top: 50px; margin-top: -50px; } } +.edit-history-header { + padding: 10px; + margin-bottom: 5px; + font-size: 16px; + font-weight: bold; + background-color: var(--bg_panel); +} + +.tweet-edit { + margin-bottom: 5px; +} + .main-tweet .tweet-content { font-size: 18px; } @@ -32,11 +50,6 @@ } } -.reply { - background-color: var(--bg_panel); - margin-bottom: 10px; -} - .thread-line { .timeline-item::before, &.timeline-item::before { diff --git a/src/types.nim b/src/types.nim index 33298d2..996f572 100644 --- a/src/types.nim +++ b/src/types.nim @@ -222,6 +222,7 @@ type gif*: Option[Gif] video*: Option[Video] photos*: seq[Photo] + history*: seq[int64] Tweets* = seq[Tweet] @@ -242,6 +243,10 @@ type after*: Chain replies*: Result[Chain] + EditHistory* = object + latest*: Tweet + history*: Tweets + Timeline* = Result[Tweets] Profile* = object diff --git a/src/views/general.nim b/src/views/general.nim index 23e6a10..d002777 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=26") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=27") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/status.nim b/src/views/status.nim index 96af807..fdb4ae9 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -78,3 +78,17 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode renderReplies(conv.replies, prefs, path, conv.tweet) renderToTop(focus="#m") + +proc renderEditHistory*(edits: EditHistory; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="edit-history")): + tdiv(class="latest-edit"): + tdiv(class="edit-history-header"): + text "Latest post" + renderTweet(edits.latest, prefs, path) + + tdiv(class="previous-edits"): + tdiv(class="edit-history-header"): + text "Version history" + for tweet in edits.history: + tdiv(class="tweet-edit"): + renderTweet(tweet, prefs, path) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 2db01c1..f79c571 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -211,6 +211,12 @@ proc renderMediaTags(tags: seq[User]): VNode = if i < tags.high: text ", " +proc renderLatestPost(username: string; id: int64): VNode = + buildHtml(tdiv(class="latest-post-version")): + text "There's a new version of this post. " + a(href=getLink(id, username)): + text "See the latest post" + proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="quote-media-container")): if quote.photos.len > 0: @@ -252,12 +258,16 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-text", dir="auto"): verbatim replaceUrls(quote.text, prefs) + if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: + renderQuoteMedia(quote, prefs, path) + if quote.hasThread: a(class="show-thread", href=getLink(quote)): text "Show this thread" - if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: - renderQuoteMedia(quote, prefs, path) + if quote.history.len > 0 and quote.id != max(quote.history): + tdiv(class="quote-latest"): + text "There's a new version of this post" proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() @@ -336,8 +346,20 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) + let + hasEdits = tweet.history.len > 1 + isLatest = hasEdits and tweet.id == max(tweet.history) + if mainTweet: - p(class="tweet-published"): text &"{getTime(tweet)}" + p(class="tweet-published"): + if hasEdits and isLatest: + a(href=(getLink(tweet, focus=false) & "/history")): + text &"Last edited {getTime(tweet)}" + else: + text &"{getTime(tweet)}" + + if hasEdits and not isLatest: + renderLatestPost(tweet.user.username, max(tweet.history)) if tweet.mediaTags.len > 0: renderMediaTags(tweet.mediaTags)