Add support for Communities

Fixes #1270
This commit is contained in:
Zed 2026-06-18 13:32:20 +02:00
commit 7b27c2c629
17 changed files with 856 additions and 11 deletions

View file

@ -94,6 +94,57 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profi
js = await fetch(url)
result = parseGraphTimeline(js, after)
proc getGraphCommunity*(id: string): Future[Community] {.async.} =
if id.len == 0: return
let
url = apiReq(graphCommunity, communityVars % id)
js = await fetch(url)
result = parseGraphCommunity(js)
proc getGraphCommunityTweets*(id: string; rankingMode: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = apiReq(graphCommunityTweets, communityTweetsVars % [id, cursor, rankingMode])
js = await fetch(url)
result = parseGraphCommunityTimeline(js, after)
proc getGraphCommunityMedia*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = apiReq(graphCommunityMedia, communityMediaVars % [id, cursor])
js = await fetch(url)
result = parseGraphCommunityTimeline(js, after)
proc communitySliceReq(endpoint, variables: string): ApiReq =
let url = ApiUrl(endpoint: endpoint, params: @[("variables", variables)])
ApiReq(cookie: url, oauth: url)
proc getGraphCommunityMembers*(id: string; after=""): Future[Result[User]] {.async.} =
if id.len == 0: return
let
cursor = if after.len > 0: "\"$1\"" % after else: "null"
url = communitySliceReq(graphCommunityMembers, communityMembersVars % [id, cursor])
js = await fetch(url)
result = parseGraphCommunityMembers(js, after)
proc getGraphCommunityModerators*(id: string): Future[Result[User]] {.async.} =
if id.len == 0: return
let
url = communitySliceReq(graphCommunityModerators, communityMembersVars % [id, "null"])
js = await fetch(url)
result = parseGraphCommunityMembers(js)
proc getGraphCommunityHashtags*(id, hashtag: string; after=""): Future[Timeline] {.async.} =
if id.len == 0 or hashtag.len == 0: return
let
safeTag = multiReplace(hashtag, ("\"", ""), ("\\", ""))
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
url = apiReq(graphCommunityHashtags, communityHashtagsVars % [id, cursor, safeTag])
js = await fetch(url)
result = parseGraphCommunityTimeline(js, after)
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return
let

View file

@ -28,6 +28,13 @@ const
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery"
graphCommunity* = "-ElI1vg3dYbttVMhBhGdLw/CommunityQuery"
graphCommunityTweets* = "Mvs5UOOEkpXVMDZtUcxR-Q/CommunityTweetsTimeline"
graphCommunityMedia* = "Bt9XYnY7D3OcmZE5lhdx-A/CommunityMediaTimeline"
graphCommunityMembers* = "WSbJGJjZaVasSj9bnqSZSA/membersSliceTimeline_Query"
graphCommunityModerators* = "GBMT3GOWy5dYsYC4XJfvow/moderatorsSliceTimeline_Query"
graphCommunityHashtags* = "40DyrMxfCknGuZwE-keW_Q/CommunityHashtagsTimeline"
graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId"
graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
@ -47,6 +54,8 @@ const
"premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true,
"c9s_tweet_anatomy_moderator_badge_enabled": true,
"c9s_list_members_action_api_enabled": false,
"c9s_superc9s_indication_enabled": false,
"responsive_web_grok_analyze_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true,
"rweb_cashtags_composer_attachment_enabled": true,
@ -152,6 +161,34 @@ const
articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}"""
communityVars* = """{"communityId":"$1"}"""
communityTweetsVars* = """{
"communityId": "$1", $2
"count": 20,
"displayLocation": "Community",
"rankingMode": "$3",
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
communityMediaVars* = """{
"communityId": "$1", $2
"count": 20,
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
communityMembersVars* = """{
"communityId": "$1",
"cursor": $2
}""".replace(" ", "").replace("\n", "")
communityHashtagsVars* = """{
"communityId": "$1", $2
"count": 20,
"hashtags": ["$3"],
"withCommunity": true
}""".replace(" ", "").replace("\n", "")
userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false}"""

View file

@ -9,7 +9,7 @@ import jester
import types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about]
import routes/[
preferences, timeline, status, media, search, rss, list, debug,
preferences, timeline, status, media, search, rss, list, community, debug,
unsupported, embed, resolver, broadcast, article, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
@ -54,6 +54,7 @@ createResolverRouter(cfg)
createPrefRouter(cfg)
createTimelineRouter(cfg)
createListRouter(cfg)
createCommunityRouter(cfg)
createStatusRouter(cfg)
createSearchRouter(cfg)
createMediaRouter(cfg)
@ -126,6 +127,7 @@ routes:
extend timeline, ""
extend media, ""
extend list, ""
extend community, ""
extend preferences, ""
extend resolver, ""
extend embed, ""

View file

@ -131,6 +131,36 @@ proc parseBroadcastInfo*(js: JsonNode): Broadcast =
user: parseGraphUser(bc)
)
proc parseGraphCommunity*(js: JsonNode): Community =
if js.isNull: return
let c = ? js{"data", "communityResults", "result"}
result = Community(
id: c{"rest_id"}.getStr(c{"id_str"}.getStr),
name: c{"name"}.getStr,
description: c{"description"}.getStr,
memberCount: c{"member_count"}.getInt,
joinPolicy: c{"join_policy"}.getStr,
category: c{"primary_community_topic", "topic_name"}.getStr,
banner: c{"custom_banner_media", "media_info", "original_img_url"}.getImageStr,
creator: parseGraphUser(c{"creator_results", "result"}),
)
let createdMs = c{"created_at"}.getInt(0)
if createdMs > 0:
result.createdAt = fromUnix(createdMs div 1000).utc()
for rule in c{"rules"}:
result.rules.add CommunityRule(
name: rule{"name"}.getStr,
description: rule{"description"}.getStr
)
for item in c{"trending_hashtags_slice", "items"}:
let tag = item{"hashtag"}.getStr
if tag.len > 0:
result.hashtags.add tag
proc parseGraphList*(js: JsonNode): List =
if js.isNull: return
@ -855,3 +885,49 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
elif typ == "TimelineReplaceEntry":
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
result.bottom = instruction{"entry", "content", "value"}.getStr
proc parseGraphCommunityTimeline*(js: JsonNode; after=""): Timeline =
result = Timeline(beginning: after.len == 0)
let communityResult = js{"data", "communityResults", "result"}
let instructions = ? select(
communityResult{"ranked_community_timeline", "timeline", "instructions"},
communityResult{"community_media_timeline", "timeline", "instructions"},
communityResult{"community_filtered_timeline", "timeline", "instructions"}
)
if instructions.len == 0:
return
for i in instructions:
if i{"entries"}.notNull:
for e in i{"entries"}:
let entryId = e.getEntryId
if entryId.startsWith("tweet") or entryId.startsWith("profile-grid") or
entryId.startsWith("communities-grid"):
for tweet in extractTweetsFromEntry(e):
result.content.add tweet
elif entryId.startsWith("cursor-bottom"):
result.bottom = e{"content", "value"}.getStr
if after.len == 0 and i.getTypeName == "TimelinePinEntry":
var tweets = extractTweetsFromEntry(i{"entry"})
for tweet in tweets.mitems:
tweet.pinned = true
if tweets.len > 0:
result.content.insert(tweets, 0)
proc parseGraphCommunityMembers*(js: JsonNode; after=""): Result[User] =
result = Result[User](beginning: after.len == 0)
let r = js{"data", "communityResults", "result"}
let slice = if not r{"members_slice"}.isNull: r{"members_slice"}
else: r{"moderators_slice"}
for item in slice{"items_results"}:
let user = parseGraphUser(item{"result"})
if user.username.len > 0:
result.content.add user
let cursor = slice{"slice_info", "next_cursor"}.getStr
if cursor.len > 0:
result.bottom = cursor

View file

@ -195,6 +195,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
result = await getPhotoRail(id)
await cache(result, id)
proc cache*(data: Community) {.async.} =
if data.id.len == 0: return
await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data)))
proc getCachedCommunity*(id: string): Future[Community] {.async.} =
if id.len == 0: return
let cached = await get("cm:" & id)
if cached != redisNil:
cached.deserialize(Community)
else:
result = await getGraphCommunity(id)
await cache(result)
proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} =
if id.len == 0: return
let cached = await get("cmm:" & id)
if cached != redisNil:
cached.deserialize(seq[User])
else:
let mods = await getGraphCommunityModerators(id)
result = mods.content
await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result)))
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil
else: await get("l:" & id)

89
src/routes/community.nim Normal file
View file

@ -0,0 +1,89 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strformat
import jester
import router_utils
import ".."/[types, redis_cache, api]
import ../views/[general, timeline, community]
export community
template respCommunity*(cmty: Community; title: string; nav, vnode: typed) =
if cmty.id.len == 0 or cmty.name.len == 0:
resp Http404, showError(&"""Community "{@"id"}" not found""", cfg)
let html = renderCommunity(vnode, nav, cmty)
resp renderMain(html, request, cfg, prefs, titleText=title, banner=cmty.banner)
proc createCommunityRouter*(cfg: Config) =
router community:
get "/i/communities/@id/?":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityTweets(cmty.id, "Relevance", getCursor())
respCommunity(cmty, cmty.name,
renderCommunityTabs(QueryKind.posts, cmty),
renderTimelineTweets(tl, prefs, request.path))
get "/i/communities/@id/latest":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityTweets(cmty.id, "Recency", getCursor())
respCommunity(cmty, cmty.name & " - Latest",
renderCommunityTabs(QueryKind.replies, cmty),
renderTimelineTweets(tl, prefs, request.path))
get "/i/communities/@id/media":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityMedia(cmty.id, getCursor())
respCommunity(cmty, cmty.name & " - Media",
renderCommunityTabs(QueryKind.media, cmty),
renderTimelineTweets(tl, prefs, request.path))
get "/i/communities/@id/about":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
mods = await getCachedCommunityModerators(cmty.id)
respCommunity(cmty, cmty.name & " - About",
renderCommunityTabs(QueryKind.userList, cmty),
renderCommunityAbout(cmty, mods))
get "/i/communities/@id/members":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
members = await getGraphCommunityMembers(cmty.id, getCursor())
respCommunity(cmty, cmty.name & " - Members",
renderMemberTabs(cmty, false),
renderTimelineUsers(members, prefs, request.path))
get "/i/communities/@id/moderators":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
mods = await getCachedCommunityModerators(cmty.id)
respCommunity(cmty, cmty.name & " - Moderators",
renderMemberTabs(cmty, true),
renderTimelineUsers(Result[User](content: mods), prefs, request.path))
get "/i/communities/@id/hashtag/@tag":
cond '.' notin @"id"
let
prefs = requestPrefs()
cmty = await getCachedCommunity(@"id")
tl = await getGraphCommunityHashtags(cmty.id, @"tag", getCursor())
respCommunity(cmty, cmty.name & " - #" & @"tag",
renderHashtagHeader(cmty, @"tag"),
renderTimelineTweets(tl, prefs, request.path))

View file

@ -160,6 +160,7 @@ body.fixed-nav .container {
.verified-icon {
display: inline-block;
position: relative;
width: 14px;
height: 14px;
margin-bottom: 2px;

View file

@ -4,6 +4,7 @@
@import "card";
@import "about-account";
@import "photo-rail";
@import "community";
.profile-tabs {
@include panel(auto, 900px);

View file

@ -0,0 +1,203 @@
.community-header {
padding: 12px 15px;
border-bottom: 1px solid var(--border_grey);
background-color: var(--bg_panel);
.community-name {
font-size: 22px;
margin-bottom: 6px;
a {
color: inherit;
}
}
.community-category {
display: inline-block;
background-color: var(--bg_elements);
border: 1px solid var(--border_grey);
border-radius: 16px;
padding: 2px 12px;
font-size: 13px;
color: var(--fg_faded);
margin-bottom: 8px;
}
.community-description {
color: var(--fg_faded);
margin-bottom: 8px;
line-height: 1.4;
}
.community-member-count {
font-weight: bold;
color: inherit;
}
.community-stats {
color: var(--grey);
font-size: 14px;
}
}
.community-about {
padding: 16px 15px 15px;
background-color: var(--bg_panel);
h2 {
font-size: 18px;
margin: 0 0 12px;
}
.community-info {
border-bottom: 1px solid var(--border_grey);
padding-bottom: 12px;
}
.community-info-item {
display: flex;
gap: 10px;
padding: 8px 0;
align-items: center;
.verified-icon {
margin-left: 2px;
}
> .icon-container {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--grey);
flex-shrink: 0;
width: 26px;
height: 26px;
}
strong {
color: var(--fg_color);
}
a {
color: var(--accent);
}
}
.community-rules {
border-bottom: 1px solid var(--border_grey);
padding: 16px 0 12px;
}
.community-rules-intro {
color: var(--fg_faded);
font-size: 14px;
margin: 0 0 12px;
}
.community-rule {
display: flex;
gap: 10px;
padding: 10px 0;
align-items: flex-start;
.community-rule-number {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
border-radius: 50%;
background-color: var(--accent);
color: var(--fg_color);
font-weight: bold;
font-size: 13px;
flex-shrink: 0;
}
.community-rule-content p {
margin: 4px 0 0;
color: var(--fg_faded);
font-size: 14px;
}
}
.community-moderators {
padding-top: 16px;
h2 {
display: flex;
align-items: center;
justify-content: space-between;
}
.community-mods-link {
font-size: 14px;
font-weight: normal;
color: var(--accent);
}
}
.community-moderator {
display: flex;
gap: 10px;
padding: 8px 0;
align-items: center;
.community-mod-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.community-mod-info {
display: flex;
flex-direction: column;
}
.community-mod-name {
display: flex;
align-items: center;
font-weight: bold;
color: var(--fg_color);
.verified-icon {
margin-left: 2px;
}
}
.community-mod-username {
color: var(--fg_faded);
font-size: 14px;
}
}
}
.community-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 15px;
border-bottom: 1px solid var(--border_grey);
.community-tag {
display: inline-block;
background-color: var(--bg_elements);
border: 1px solid var(--border_grey);
border-radius: 16px;
padding: 4px 12px;
font-size: 13px;
color: var(--accent);
}
}
.community-hashtag-header {
padding: 12px 15px;
border-bottom: 1px solid var(--border_grey);
.community-hashtag-title {
font-size: 20px;
color: var(--accent);
margin: 0;
}
}

View file

@ -361,6 +361,23 @@ type
members*: int
banner*: string
CommunityRule* = object
name*: string
description*: string
Community* = object
id*: string
name*: string
description*: string
memberCount*: int
banner*: string
creator*: User
category*: string
joinPolicy*: string
createdAt*: DateTime
rules*: seq[CommunityRule]
hashtags*: seq[string]
GlobalObjects* = ref object
tweets*: Table[string, Tweet]
users*: Table[string, User]

128
src/views/community.nim Normal file
View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: AGPL-3.0-only
import strutils, strformat, times
import karax/[karaxdsl, vdom]
import renderutils
import ".."/[types, utils, formatters]
proc renderCommunityTabs*(kind: QueryKind; community: Community): VNode =
let
path = &"/i/communities/{community.id}"
q = Query(kind: kind)
buildHtml(tdiv):
ul(class="tab"):
li(class=q.getTabClass(posts)):
a(href=path): text "Top"
li(class=q.getTabClass(replies)):
a(href=(path & "/latest")): text "Latest"
li(class=q.getTabClass(media)):
a(href=(path & "/media")): text "Media"
li(class=q.getTabClass(userList)):
a(href=(path & "/about")): text "About"
if community.hashtags.len > 0:
tdiv(class="community-tags"):
for tag in community.hashtags:
let bare = tag.strip(chars={'#'})
a(class="community-tag",
href=(&"/i/communities/{community.id}/hashtag/{bare}")):
text tag
proc renderMemberTabs*(community: Community; isModerators: bool): VNode =
let path = &"/i/communities/{community.id}"
buildHtml(ul(class="tab")):
li(class=(if not isModerators: "tab-item active" else: "tab-item")):
a(href=(path & "/members")): text "All"
li(class=(if isModerators: "tab-item active" else: "tab-item")):
a(href=(path & "/moderators")): text "Moderators"
proc renderHashtagHeader*(community: Community; tag: string): VNode =
buildHtml(tdiv(class="community-hashtag-header")):
h2(class="community-hashtag-title"): text "#" & tag
proc renderCommunityAbout*(community: Community; moderators: seq[User]): VNode =
buildHtml(tdiv(class="community-about")):
tdiv(class="community-info"):
h2: text "Community Info"
tdiv(class="community-info-item"):
icon "group"
if community.joinPolicy == "Open":
text "Anyone can join this Community."
else:
text "Membership is by approval only."
tdiv(class="community-info-item"):
icon "info"
text "All Communities are publicly visible."
tdiv(class="community-info-item"):
icon "calendar"
let
date = community.createdAt.format("MMMM d, yyyy")
creator = community.creator.username
span:
text &"Created {date} by "
a(href=(&"/{creator}")): text &"@{creator}"
if community.creator.verifiedType != none:
verifiedIcon(community.creator)
if community.rules.len > 0:
tdiv(class="community-rules"):
h2: text "Rules"
p(class="community-rules-intro"):
text "These are set and enforced by Community admins and are in addition to "
a(href="https://help.x.com/rules-and-policies/x-rules"): text "X's rules"
text "."
for i, rule in community.rules:
tdiv(class="community-rule"):
span(class="community-rule-number"): text $(i + 1)
tdiv(class="community-rule-content"):
strong: text rule.name
if rule.description.len > 0:
p: text rule.description
if moderators.len > 0:
tdiv(class="community-moderators"):
h2:
text "Moderators"
a(class="community-mods-link",
href=(&"/i/communities/{community.id}/moderators")):
text "See all"
for user in moderators:
tdiv(class="community-moderator"):
a(href=(&"/{user.username}")):
genImg(user.getUserPic("_bigger"), class="community-mod-avatar")
tdiv(class="community-mod-info"):
a(href=(&"/{user.username}"), class="community-mod-name"):
text user.fullname
if user.verifiedType != none:
verifiedIcon(user)
a(href=(&"/{user.username}"), class="community-mod-username"):
text &"@{user.username}"
proc renderCommunity*(body, nav: VNode; community: Community): VNode =
buildHtml(tdiv(class="timeline-container")):
if community.banner.len > 0:
tdiv(class="timeline-banner"):
a(href=getPicUrl(community.banner), target="_blank"):
genImg(community.banner)
tdiv(class="community-header"):
h1(class="community-name"):
a(href=(&"/i/communities/{community.id}")): text community.name
if community.category.len > 0:
span(class="community-category"): text community.category
if community.description.len > 0:
tdiv(class="community-description"):
text community.description
tdiv(class="community-stats"):
a(class="community-member-count",
href=(&"/i/communities/{community.id}/members")):
text insertSep($community.memberCount, ',')
text " Members"
nav
body

View file

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

View file

@ -18,7 +18,7 @@ proc getMediumPic*(url: string): string =
result &= mediumWebp
result = getPicUrl(result)
proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
proc icon*(icon: string; label=""; title=""; class=""; href=""): VNode =
var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")):
@ -27,8 +27,8 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
else:
span(class=c, title=title)
if text.len > 0:
text " " & text
if label.len > 0:
text " " & label
template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none:

View file

@ -154,7 +154,8 @@ proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string;
else: renderThread(thread, prefs, path, bigThumb)
else:
for thread in filtered:
if thread.len == 1: renderTweet(thread[0], prefs, path)
if thread.len == 1:
renderTweet(thread[0], prefs, path)
else: renderThread(thread, prefs, path)
var cursor = getSearchMaxId(results, path)

View file

@ -27,14 +27,18 @@ proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode =
if preview.previewText.len > 0:
p(class="card-description"): text preview.previewText
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs;
path = ""): VNode =
buildHtml(tdiv):
if pinned:
let pinnedLabel =
if "/i/communities/" in path: "Pinned by Community mods"
else: "Pinned Tweet"
tdiv(class="pinned"):
span: icon "pin", "Pinned Tweet"
span: icon("pin", pinnedLabel)
elif retweet.len > 0:
tdiv(class="retweet-header"):
span: icon "retweet", retweet & " retweeted"
span: icon("retweet", retweet & " retweeted")
tdiv(class="tweet-header"):
a(class="tweet-avatar", href=("/" & tweet.user.username)):
@ -358,7 +362,8 @@ proc renderLocation*(tweet: Tweet): string =
return $node
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
last=false; mainTweet=false; afterTweet=false;
bigThumb=false): VNode =
var divClass = class
if index == -1 or last:
divClass = "thread-last " & class
@ -391,7 +396,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
a(class="tweet-link", href=getLink(tweet))
tdiv(class="tweet-body"):
renderHeader(tweet, retweet, pinned, prefs)
renderHeader(tweet, retweet, pinned, prefs, path)
if not afterTweet and index == 0 and tweet.reply.len > 0 and
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):

0
temp
View file

211
tests/test_community.py Normal file
View file

@ -0,0 +1,211 @@
from base import BaseTestCase
from parameterized import parameterized
COMMUNITY_ID = '1493446837214187523'
COMMUNITY_PATH = f'i/communities/{COMMUNITY_ID}'
class CommunityTest(BaseTestCase):
def test_top_page_loads(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.community-header')
self.assert_text('Build in Public', '.community-name')
def test_banner_visible(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.timeline-banner img')
def test_member_count(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.community-member-count')
self.assert_text('Members', '.community-member-count')
def test_description_visible(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.community-description')
def test_tabs_present(self):
self.open_nitter(COMMUNITY_PATH)
tabs = self.find_elements('.tab a')
labels = [t.text for t in tabs]
self.assertEqual(labels, ['Top', 'Latest', 'Media', 'About'])
def test_top_tab_active(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.tab .active a[href$="/' + COMMUNITY_ID + '"]')
def test_top_has_tweets(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.timeline-item .tweet-body')
def test_top_has_pagination(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.show-more')
self.assert_text('Load more', '.show-more')
def test_latest_has_tweets(self):
self.open_nitter(f'{COMMUNITY_PATH}/latest')
self.assert_element_visible('.timeline-item .tweet-body')
def test_latest_tab_active(self):
self.open_nitter(f'{COMMUNITY_PATH}/latest')
self.assert_element_visible('.tab .active a[href$="/latest"]')
def test_media_has_tweets(self):
self.open_nitter(f'{COMMUNITY_PATH}/media')
self.assert_element_visible('.timeline-item .tweet-body')
def test_media_tab_active(self):
self.open_nitter(f'{COMMUNITY_PATH}/media')
self.assert_element_visible('.tab .active a[href$="/media"]')
def test_about_page(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_element_visible('.community-about')
self.assert_text('Community Info', '.community-info h2')
def test_about_rules(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_element_visible('.community-rules')
self.assert_text('Rules', '.community-rules h2')
rules = self.find_elements('.community-rule')
self.assertGreater(len(rules), 0)
def test_about_creator(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_text('Created', '.community-info')
link = self.find_element('.community-info-item a')
self.assertTrue(link.text.startswith('@'))
self.assertGreater(len(link.text), 1)
def test_about_tab_active(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_element_visible('.tab .active a[href$="/about"]')
def test_about_moderators(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_element_visible('.community-moderators')
self.assert_text('Moderators', '.community-moderators h2')
mods = self.find_elements('.community-moderator')
self.assertGreater(len(mods), 0)
def test_about_moderators_have_avatars(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
avatars = self.find_elements('.community-mod-avatar')
self.assertGreater(len(avatars), 0)
def test_about_moderators_link_to_profiles(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
links = self.find_elements('.community-mod-username')
self.assertGreater(len(links), 0)
for link in links:
self.assertTrue(link.text.startswith('@'))
self.assertTrue(link.get_attribute('href').startswith('http'))
def test_about_see_all_link(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
link = self.find_element('.community-mods-link')
self.assertEqual(link.text, 'See all')
self.assertIn('/moderators', link.get_attribute('href'))
def test_members_page(self):
self.open_nitter(f'{COMMUNITY_PATH}/members')
self.assert_element_visible('.timeline-item')
users = self.find_elements('.timeline-item .username')
self.assertGreater(len(users), 0)
def test_members_has_member_tabs(self):
self.open_nitter(f'{COMMUNITY_PATH}/members')
tabs = self.find_elements('.tab a')
labels = [t.text for t in tabs]
self.assertEqual(labels, ['All', 'Moderators'])
def test_members_all_tab_active(self):
self.open_nitter(f'{COMMUNITY_PATH}/members')
self.assert_element_visible('.tab .active a[href$="/members"]')
def test_members_count_is_link(self):
self.open_nitter(COMMUNITY_PATH)
link = self.find_element('.community-member-count')
self.assertIn('Members', link.text)
self.assertIn('/members', link.get_attribute('href'))
def test_moderators_page(self):
self.open_nitter(f'{COMMUNITY_PATH}/moderators')
self.assert_element_visible('.timeline-item')
users = self.find_elements('.timeline-item .username')
self.assertGreater(len(users), 0)
def test_moderators_tab_active(self):
self.open_nitter(f'{COMMUNITY_PATH}/moderators')
self.assert_element_visible('.tab .active a[href$="/moderators"]')
def test_moderators_has_member_tabs(self):
self.open_nitter(f'{COMMUNITY_PATH}/moderators')
tabs = self.find_elements('.tab a')
labels = [t.text for t in tabs]
self.assertEqual(labels, ['All', 'Moderators'])
def test_pinned_tweet_label(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.pinned')
self.assert_text('Pinned by Community mods', '.pinned')
def test_hashtags_visible(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.community-tags')
tags = self.find_elements('.community-tag')
self.assertGreater(len(tags), 0)
def test_hashtags_are_links(self):
self.open_nitter(COMMUNITY_PATH)
tags = self.find_elements('.community-tag')
for tag in tags:
href = tag.get_attribute('href')
self.assertIn('/hashtag/', href)
self.assertTrue(tag.text.startswith('#'))
def test_hashtag_page(self):
self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
self.assert_element_visible('.timeline-item .tweet-body')
def test_hashtag_shows_header(self):
self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
self.assert_element_visible('.community-header')
self.assert_text('Build in Public', '.community-name')
def test_hashtag_shows_tag_title(self):
self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
self.assert_element_visible('.community-hashtag-header')
self.assert_text('#buildinpublic', '.community-hashtag-title')
def test_hashtag_no_main_tabs(self):
self.open_nitter(f'{COMMUNITY_PATH}/hashtag/buildinpublic')
tabs = self.find_elements('.tab a')
tab_labels = [t.text for t in tabs]
self.assertNotIn('Top', tab_labels)
self.assertNotIn('About', tab_labels)
def test_category_visible(self):
self.open_nitter(COMMUNITY_PATH)
self.assert_element_visible('.community-category')
def test_about_join_policy(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_text('Anyone can join', '.community-info')
def test_about_visibility_note(self):
self.open_nitter(f'{COMMUNITY_PATH}/about')
self.assert_text('publicly visible', '.community-info')
def test_404_invalid_id(self):
self.open_nitter('i/communities/999')
self.assert_element_visible('.error-panel')
self.assert_text('not found', '.error-panel')
@parameterized.expand(['', '/latest', '/media', '/about',
'/members', '/moderators'])
def test_page_no_error(self, suffix):
self.open_nitter(f'{COMMUNITY_PATH}{suffix}')
self.assert_element_not_visible('.error-panel')