mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +00:00
parent
7f7092fbf5
commit
2f07fa6151
10 changed files with 117 additions and 21 deletions
18
src/api.nim
18
src/api.nim
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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
37
tests/test_reply_sort.py
Normal 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}')
|
||||||
Loading…
Reference in a new issue