Add followers and following pages

Fixes #187
This commit is contained in:
Zed 2026-06-21 06:11:59 +02:00
commit 55a331cc50
9 changed files with 185 additions and 9 deletions

View file

@ -201,6 +201,28 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
js = await fetchRaw(url)
result = parseGraphListMembers(js, after)
proc getGraphUserConnections(userId: string; endpoint: string; kind: QueryKind;
after=""): Future[Result[User]] {.async.} =
if userId.len == 0: return
var variables = %*{
"userId": userId,
"count": 20,
"includePromotedContent": false,
"withGrokTranslatedBio": true
}
if after.len > 0:
variables["cursor"] = %after
let
url = apiReq(endpoint, $variables)
js = await fetchRaw(url)
result = parseGraphFollowers(js, after, kind)
proc getGraphFollowers*(userId: string; after=""): Future[Result[User]] {.async.} =
result = await getGraphUserConnections(userId, graphFollowers, followers, after)
proc getGraphFollowing*(userId: string; after=""): Future[Result[User]] {.async.} =
result = await getGraphUserConnections(userId, graphFollowing, following, after)
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
if id.len == 0: return
let

View file

@ -42,6 +42,9 @@ const
graphAudioSpace* = "rWRLsOhNJ2xjpI1tREYurQ/AudioSpaceById"
restLiveStream* = "1.1/live_video_stream/status/"
graphFollowers* = "9jsVJ9l2uXUIKslHvJqIhw/Followers"
graphFollowing* = "OLm4oHZBfqWx8jbcEhWoFw/Following"
gqlFeatures* = """{
"rweb_video_screen_enabled": false,
"rweb_cashtags_enabled": true,

View file

@ -1,6 +1,6 @@
import options, strutils
import jsony
import user, utils, ../types/[graphuser, graphlistmembers]
import user, utils, ../types/[graphuser, graphlistmembers, graphfollowers]
from ../../types import User, VerifiedType, Result, Query, QueryKind
proc parseUserResult*(userResult: UserResult): User =
@ -67,3 +67,25 @@ proc parseGraphListMembers*(json, cursor: string): Result[User] =
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value
proc parseGraphFollowers*(json, cursor: string; kind: QueryKind): Result[User] =
result = Result[User](
beginning: cursor.len == 0,
query: Query(kind: kind)
)
if json.len == 0 or json[0] != '{':
return
let raw = json.fromJson(GraphFollowers)
for instruction in raw.data.user.result.timeline.timeline.instructions:
if instruction.kind == "TimelineAddEntries":
for entry in instruction.entries:
case entry.content.entryType
of TimelineTimelineItem:
let userResult = entry.content.itemContent.userResults.result
if userResult.restId.len > 0:
result.content.add parseUserResult(userResult)
of TimelineTimelineCursor:
if entry.content.cursorType == "Bottom":
result.bottom = entry.content.value

View file

@ -0,0 +1,17 @@
# SPDX-License-Identifier: AGPL-3.0-only
import graphlistmembers
type
GraphFollowers* = object
data*: tuple[user: UserWrapper]
UserWrapper = object
result*: UserResultWrapper
UserResultWrapper = object
timeline*: tuple[timeline: graphlistmembers.Timeline]
# Hook to normalize snake_case field from API to camelCase used by shared types
proc renameHook*(v: var Content; fieldName: var string) =
if fieldName == "user_results":
fieldName = "userResults"

View file

@ -7,10 +7,10 @@ type
List = object
membersTimeline*: tuple[timeline: Timeline]
Timeline = object
Timeline* = object
instructions*: seq[Instruction]
Instruction = object
Instruction* = object
kind*: string
entries*: seq[tuple[content: Content]]
@ -18,7 +18,7 @@ type
TimelineTimelineItem
TimelineTimelineCursor
Content = object
Content* = object
case entryType*: ContentEntryType
of TimelineTimelineItem:
itemContent*: tuple[userResults: UserData]

View file

@ -135,6 +135,39 @@ proc createTimelineRouter*(cfg: Config) =
resp renderMain(aboutHtml, request, cfg, prefs,
"About @" & info.username)
get "/@name/@kind/?":
cond @"kind" in ["followers", "following"]
cond '.' notin @"name"
cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
let
prefs = requestPrefs()
name = @"name"
userId = await getUserId(name)
if userId.len == 0:
resp Http404, showError("User \"" & name & "\" not found", cfg)
if userId == "suspended":
resp showError(getSuspended(name), cfg)
let
cursor = getCursor()
isFollowers = @"kind" == "followers"
user = await getCachedUser(name)
results = if isFollowers: await getGraphFollowers(userId, cursor)
else: await getGraphFollowing(userId, cursor)
if user.protected:
resp renderMain(renderProtected(user.username), request, cfg, prefs,
"Protected account @" & name)
let
tab = if isFollowers: "Followers" else: "Following"
title = (if isFollowers: "People following @" else: "People followed by @") & name
html = renderUserList(user, results, prefs, request.path, tab)
resp renderMain(html, request, cfg, prefs, title,
images = @[user.getUserPic("_400x400")])
get "/@name/?@tab?/?":
cond '.' notin @"name"
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]

View file

@ -61,6 +61,7 @@ type
rateLimited = 88
expiredToken = 89
listIdOrSlug = 112
timelineUnavailable = 131
tweetNotFound = 144
tweetNotAuthorized = 179
forbidden = 200
@ -173,7 +174,7 @@ type
variants*: seq[VideoVariant]
QueryKind* = enum
posts, replies, media, users, tweets, userList
posts, replies, media, users, tweets, userList, followers, following
Query* = object
kind*: QueryKind

View file

@ -2,7 +2,7 @@
import strutils, strformat
import karax/[karaxdsl, vdom, vstyles]
import renderutils, search
import renderutils, search, timeline
import ".."/[types, utils, formatters]
proc renderStat(num: int; class: string; text=""): VNode =
@ -12,6 +12,13 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"):
text insertSep($num, ',')
proc renderStatLink(num: int; class, href: string): VNode =
buildHtml(li(class=class)):
a(href=href):
span(class="profile-stat-header"): text capitalizeAscii(class)
span(class="profile-stat-num"):
text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
buildHtml(tdiv(class="profile-card")):
tdiv(class="profile-card-info"):
@ -65,8 +72,8 @@ proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
tdiv(class="profile-card-extra-links"):
ul(class="profile-statlist"):
renderStat(user.tweets, "posts", text="Tweets")
renderStat(user.following, "following")
renderStat(user.followers, "followers")
renderStatLink(user.following, "following", &"/{user.username}/following")
renderStatLink(user.followers, "followers", &"/{user.username}/followers")
renderStat(user.likes, "likes")
proc renderPhotoRail(profile: Profile): VNode =
@ -99,7 +106,7 @@ proc renderBanner(banner: string): VNode =
else:
a(href=getPicUrl(banner), target="_blank"): genImg(banner)
proc renderProtected(username: string): VNode =
proc renderProtected*(username: string): VNode =
buildHtml(tdiv(class="timeline-container")):
tdiv(class="timeline-header timeline-protected"):
h2: text "This account's tweets are protected."
@ -128,3 +135,31 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
renderProtected(profile.user.username)
else:
renderTweetSearch(profile.tweets, prefs, path, profile.pinned)
proc renderFollowTabs(user: User; activeTab: string): VNode =
buildHtml(ul(class="tab")):
for tab in ["Following", "Followers"]:
li(class=(if activeTab == tab: "tab-item active" else: "tab-item")):
a(href=(&"/{user.username}/{tab.toLowerAscii()}")): text tab
proc renderUserList*(user: User; results: Result[User]; prefs: Prefs;
path, activeTab: string): VNode =
# Check if we've loaded all results (for page 1)
var displayResults = results
if results.beginning:
let expectedCount = if activeTab == "Followers": user.followers else: user.following
if results.content.len >= expectedCount:
displayResults.bottom = ""
buildHtml(tdiv(class="profile-tabs")):
if not prefs.hideBanner:
tdiv(class="profile-banner"):
renderBanner(user.banner)
let sticky = if prefs.stickyProfile: " sticky" else: ""
tdiv(class=("profile-tab" & sticky)):
renderUserCard(user, prefs, AccountInfo())
tdiv(class="timeline-container"):
renderFollowTabs(user, activeTab)
renderTimelineUsers(displayResults, prefs, path)

43
tests/test_followers.py Normal file
View file

@ -0,0 +1,43 @@
# SPDX-License-Identifier: AGPL-3.0-only
from base import BaseTestCase, Timeline
class FollowersTest(BaseTestCase):
"""Tests for followers and following pages"""
def test_followers_page_loads(self):
"""Test that followers page loads and shows users"""
self.open_nitter('jack/followers')
self.assert_title_contains('following @jack')
# Check for user list
self.assert_element('.timeline-item')
def test_following_page_loads(self):
"""Test that following page loads and shows users"""
self.open_nitter('jack/following')
self.assert_title_contains('followed by @jack')
# Check for user list
self.assert_element('.timeline-item')
def test_followers_has_navigation_tabs(self):
"""Test that followers page has following/followers tabs"""
self.open_nitter('jack/followers')
self.assert_element('.profile-statlist')
# Check for Following and Followers links
self.assert_element('a[href="/jack/following"]')
self.assert_element('a[href="/jack/followers"]')
def test_following_pagination(self):
"""Test that following page has load more button or scroll-to-top"""
self.open_nitter('jack/following')
# Should have either more button or top-ref (scroll to top arrow)
try:
self.assert_element('.show-more')
except:
self.assert_element('.top-ref')
def test_nonexistent_user_followers(self):
"""Test 404 for non-existent user"""
self.open_nitter('this_user_definitely_does_not_exist_12345/followers')
self.assert_element('.error-panel')
self.assert_text_visible('not found')