Переглянути джерело

Add full support for tweet edit history

Fixes #700
Zed 6 місяців тому
батько
коміт
a15d1ce16b

+ 7 - 0
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:

+ 41 - 33
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

+ 8 - 4
src/formatters.nim

@@ -165,14 +165,18 @@ proc getDuration*(video: Video): string =
   else:
     return &"{min mod 60}:{sec mod 60:02}"
 
-proc getLink*(tweet: Tweet; focus=true): string =
-  if tweet.id == 0: return
-  var username = tweet.user.username
+proc getLink*(id: int64; username="i"; focus=true): string =
+  var username = username
   if username.len == 0:
     username = "i"
-  result = &"/{username}/status/{tweet.id}"
+  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
+  return getLink(tweet.id, username, focus)
+
 proc getTwitterLink*(path: string; params: Table[string, string]): string =
   var
     username = params.getOrDefault("name")

+ 28 - 1
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)

+ 21 - 1
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/?":

+ 3 - 6
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);
 }

+ 14 - 2
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;
+  }
+}

+ 5 - 0
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;

+ 22 - 9
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 {

+ 5 - 0
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

+ 1 - 1
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:

+ 14 - 0
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)

+ 25 - 3
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)