Sort replies by relevance, recency, or likes

Fixes #1336
This commit is contained in:
Zed 2026-07-08 00:11:52 +02:00
commit 2f07fa6151
10 changed files with 117 additions and 21 deletions

View file

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

View file

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

View file

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

View file

@ -19,6 +19,39 @@
margin-bottom: 10px; 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, .main-tweet,
.replies, .replies,
.edit-history > div { .edit-history > div {

View file

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

View file

@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): 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") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
if theme.len > 0: if theme.len > 0:

View file

@ -28,7 +28,22 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
if thread.hasMore: if thread.hasMore:
renderMoreReplies(thread) 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")): buildHtml(tdiv(class="replies", id="r")):
var hasReplies = false var hasReplies = false
var replyCount = 0 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 hasReplies and replies.bottom.len > 0:
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies: 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 hasAfter = conv.after.content.len > 0
let threadId = conv.tweet.threadId let threadId = conv.tweet.threadId
buildHtml(tdiv(class="conversation")): buildHtml(tdiv(class="conversation")):
@ -75,7 +92,8 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
if not conv.replies.beginning: if not conv.replies.beginning:
renderNewer(Query(), getLink(conv.tweet), focus="#r") renderNewer(Query(), getLink(conv.tweet), focus="#r")
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: 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") renderToTop(focus="#m")

View file

@ -50,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
a(href=(p & url)): a(href=(p & url)):
text "Load newest" 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")): 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" text "Load more"
proc renderNoMore(): VNode = proc renderNoMore(): VNode =

View file

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

37
tests/test_reply_sort.py Normal file
View file

@ -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}')