diff --git a/src/api.nim b/src/api.nim index f73ef10..23fbcec 100644 --- a/src/api.nim +++ b/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 diff --git a/src/consts.nim b/src/consts.nim index 899a06c..afef54d 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -92,6 +92,7 @@ const tweetVars* = """{ "postId": "$1", $2 + "ranking_mode": "$3", "includeHasBirdwatchNotes": false, "includePromotedContent": false, "withBirdwatchNotes": true, diff --git a/src/routes/status.nim b/src/routes/status.nim index f7fb1bb..870b6d7 100644 --- a/src/routes/status.nim +++ b/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) diff --git a/src/sass/tweet/thread.scss b/src/sass/tweet/thread.scss index c5165d7..c7b50ba 100644 --- a/src/sass/tweet/thread.scss +++ b/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 { diff --git a/src/types.nim b/src/types.nim index a490316..5fac273 100644 --- a/src/types.nim +++ b/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 diff --git a/src/views/general.nim b/src/views/general.nim index 37213c2..e6203c7 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=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: diff --git a/src/views/status.nim b/src/views/status.nim index fdb4ae9..39af8fa 100644 --- a/src/views/status.nim +++ b/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") diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 9456bf9..0e45dc2 100644 --- a/src/views/timeline.nim +++ b/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 = diff --git a/tests/base.py b/tests/base.py index 6d795e1..f5e0056 100644 --- a/tests/base.py +++ b/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): diff --git a/tests/test_reply_sort.py b/tests/test_reply_sort.py new file mode 100644 index 0000000..1d89e5d --- /dev/null +++ b/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}')