Преглед изворни кода

Sort replies by relevance, recency, or likes

Fixes #1336
Zed пре 2 месеци
родитељ
комит
2f07fa6151
10 измењених фајлова са 117 додато и 21 уклоњено
  1. 9 9
      src/api.nim
  2. 1 0
      src/consts.nim
  3. 7 5
      src/routes/status.nim
  4. 33 0
      src/sass/tweet/thread.scss
  5. 3 0
      src/types.nim
  6. 1 1
      src/views/general.nim
  7. 22 4
      src/views/status.nim
  8. 2 2
      src/views/timeline.nim
  9. 2 0
      tests/base.py
  10. 37 0
      tests/test_reply_sort.py

+ 9 - 9
src/api.nim

@@ -35,8 +35,8 @@ proc userTweetsUrl(id: string; cursor: string): ApiReq =
 proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
   return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], userTweetsFieldToggles, skipTid=true)
 
-proc tweetDetailUrl(id: string; cursor: string): ApiReq =
-  return apiReq(graphTweet, tweetVars % [id, cursor])
+proc tweetDetailUrl(id, cursor: string; mode = Relevance): ApiReq =
+  return apiReq(graphTweet, tweetVars % [id, cursor, $mode])
   # let cookieVars = tweetDetailVars % [id, cursor]
   # result = ApiReq(
   #   cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
@@ -230,21 +230,21 @@ proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
     js = await fetch(url)
   result = parseGraphTweetResult(js)
 
-proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
+proc getGraphTweet(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} =
   if id.len == 0: return
   let
     cursor = cursorParam(after)
-    js = await fetch(tweetDetailUrl(id, cursor))
+    js = await fetch(tweetDetailUrl(id, cursor, mode))
   result = parseGraphConversation(js, id)
 
-proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
-  result = (await getGraphTweet(id, after)).replies
+proc getReplies*(id, after: string; mode = Relevance): Future[Result[Chain]] {.async.} =
+  result = (await getGraphTweet(id, after, mode)).replies
   result.beginning = after.len == 0
 
-proc getTweet*(id: string; after=""): Future[Conversation] {.async.} =
-  result = await getGraphTweet(id)
+proc getTweet*(id: string; after=""; mode = Relevance): Future[Conversation] {.async.} =
+  result = await getGraphTweet(id, mode=mode)
   if after.len > 0:
-    result.replies = await getReplies(id, after)
+    result.replies = await getReplies(id, after, mode)
 
 proc getGraphEditHistory*(id: string): Future[EditHistory] {.async.} =
   if id.len == 0: return

+ 1 - 0
src/consts.nim

@@ -92,6 +92,7 @@ const
   tweetVars* = """{
   "postId": "$1",
   $2
+  "ranking_mode": "$3",
   "includeHasBirdwatchNotes": false,
   "includePromotedContent": false,
   "withBirdwatchNotes": true,

+ 7 - 5
src/routes/status.nim

@@ -21,16 +21,18 @@ proc createStatusRouter*(cfg: Config) =
       if id.len > 19 or id.any(c => not c.isDigit):
         resp Http404, showError("Invalid tweet ID", cfg)
 
-      let prefs = requestPrefs()
+      let
+        prefs = requestPrefs()
+        sort = parseEnum[RankingMode](@"sort".toLowerAscii.capitalizeAscii, Relevance)
 
       # used for the infinite scroll feature
       if @"scroll".len > 0:
-        let replies = await getReplies(id, getCursor())
+        let replies = await getReplies(id, getCursor(), sort)
         if replies.content.len == 0:
           resp Http204
-        resp $renderReplies(replies, prefs, getPath())
+        resp $renderReplies(replies, prefs, getPath(), sort=sort)
 
-      let conv = await getTweet(id, getCursor())
+      let conv = await getTweet(id, getCursor(), sort)
 
       if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
         var error = "Tweet not found"
@@ -64,7 +66,7 @@ proc createStatusRouter*(cfg: Config) =
         elif card.video.isSome():
           images = @[card.video.get().thumb]
 
-      let html = renderConversation(conv, prefs, getPath() & "#m")
+      let html = renderConversation(conv, prefs, getPath() & "#m", sort)
       resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
                       images=images, video=video)
 

+ 33 - 0
src/sass/tweet/thread.scss

@@ -19,6 +19,39 @@
   margin-bottom: 10px;
 }
 
+.reply-sort {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 2px 14px;
+  margin-bottom: 10px;
+  padding: 8px 12px;
+  background-color: var(--bg_panel);
+  font-size: 14px;
+}
+
+.reply-sort-label {
+  color: var(--fg_faded);
+  margin-right: 2px;
+}
+
+.reply-sort-option {
+  color: var(--tab);
+  font-weight: bold;
+  text-decoration: none;
+  border-bottom: 0.1rem solid transparent;
+
+  &:hover {
+    color: var(--fg_color);
+    text-decoration: none;
+  }
+
+  &.active {
+    color: var(--tab_selected);
+    border-bottom-color: var(--tab_selected);
+  }
+}
+
 .main-tweet,
 .replies,
 .edit-history > div {

+ 3 - 0
src/types.nim

@@ -176,6 +176,9 @@ type
   QueryKind* = enum
     posts, replies, media, users, tweets, userList, followers, following
 
+  RankingMode* = enum
+    Relevance, Recency, Likes
+
   Query* = object
     kind*: QueryKind
     view*: string

+ 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=45")
+    link(rel="stylesheet", type="text/css", href="/css/style.css?v=46")
     link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
 
     if theme.len > 0:

+ 22 - 4
src/views/status.nim

@@ -28,7 +28,22 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
     if thread.hasMore:
       renderMoreReplies(thread)
 
-proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
+proc renderReplySort(sort: RankingMode): VNode =
+  buildHtml(tdiv(class="reply-sort")):
+    span(class="reply-sort-label"): text "Sort replies:"
+    for mode in RankingMode:
+      let
+        cls = if mode == sort: "reply-sort-option active"
+              else: "reply-sort-option"
+        label = case mode
+                of Relevance: "Relevant"
+                of Recency: "Recent"
+                of Likes: "Liked"
+      a(class=cls, href=("?sort=" & $mode & "#r")):
+        text label
+
+proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string;
+                    tweet: Tweet = nil; sort = Relevance): VNode =
   buildHtml(tdiv(class="replies", id="r")):
     var hasReplies = false
     var replyCount = 0
@@ -40,9 +55,11 @@ proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: T
 
     if hasReplies and replies.bottom.len > 0:
       if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
-        renderMore(Query(), replies.bottom, focus="#r")
+        let extra = if sort == Relevance: "" else: "sort=" & $sort & "&"
+        renderMore(Query(), replies.bottom, focus="#r", extra=extra)
 
-proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
+proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
+                         sort = Relevance): VNode =
   let hasAfter = conv.after.content.len > 0
   let threadId = conv.tweet.threadId
   buildHtml(tdiv(class="conversation")):
@@ -75,7 +92,8 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
       if not conv.replies.beginning:
         renderNewer(Query(), getLink(conv.tweet), focus="#r")
       if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
-        renderReplies(conv.replies, prefs, path, conv.tweet)
+        renderReplySort(sort)
+        renderReplies(conv.replies, prefs, path, conv.tweet, sort)
 
     renderToTop(focus="#m")
 

+ 2 - 2
src/views/timeline.nim

@@ -50,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
     a(href=(p & url)):
       text "Load newest"
 
-proc renderMore*(query: Query; cursor: string; focus=""): VNode =
+proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
   buildHtml(tdiv(class="show-more")):
-    a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
+    a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
       text "Load more"
 
 proc renderNoMore(): VNode =

+ 2 - 0
tests/base.py

@@ -71,6 +71,8 @@ class Conversation(object):
     thread = '.reply'
     tweet = '.timeline-item'
     tweet_text = '.tweet-content'
+    reply_sort = '.reply-sort'
+    reply_sort_active = '.reply-sort-option.active'
 
 
 class Poll(object):

+ 37 - 0
tests/test_reply_sort.py

@@ -0,0 +1,37 @@
+from parameterized import parameterized
+
+from base import BaseTestCase, Conversation
+
+sort_modes = [
+    ['jack/status/20', 'Relevant'],
+    ['jack/status/20?sort=relevance', 'Relevant'],
+    ['jack/status/20?sort=recency', 'Recent'],
+    ['jack/status/20?sort=likes', 'Liked'],
+    ['jack/status/20?sort=garbage', 'Relevant'],
+    ['jack/status/20?sort=%3Cscript%3E', 'Relevant'],
+]
+
+
+class ReplySortTest(BaseTestCase):
+    @parameterized.expand(sort_modes)
+    def test_active_mode(self, page, expected_active):
+        self.open_nitter(page)
+        self.assert_element_visible(Conversation.reply_sort)
+        active = self.get_text(Conversation.reply_sort_active)
+        self.assert_equal(active.strip(), expected_active)
+
+    def test_all_three_options_present(self):
+        self.open_nitter('jack/status/20')
+        options = self.find_elements('.reply-sort-option')
+        labels = [o.text.strip() for o in options]
+        self.assert_equal(labels, ['Relevant', 'Recent', 'Liked'])
+
+    def test_option_links_carry_sort_param(self):
+        self.open_nitter('jack/status/20')
+        for slug in ['Relevance', 'Recency', 'Likes']:
+            self.assert_element(f'.reply-sort-option[href="?sort={slug}#r"]')
+
+    def test_load_more_preserves_sort(self):
+        self.open_nitter('jack/status/20?sort=Likes')
+        href = self.get_attribute('.replies .show-more a', 'href')
+        self.assert_true('sort=Likes' in href, f'sort missing from: {href}')