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) js = await fetch(url)
result = parseGraphTimeline(js, after) 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.} = proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
if id.len == 0: return if id.len == 0: return
let let

View file

@ -28,6 +28,13 @@ const
graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline" graphListTweets* = "0QJtcuMzVywHGAWD6Dtjlw/ListTimeline"
graphAboutAccount* = "zUnx-DLN9dkwOkNhTLySjg/AboutAccountQuery" 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" graphTweetResultByRestId* = "qtXMy1p5Y62uCskc_NUPJw/TweetResultByRestId"
graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds" graphTweetResultsByRestIds* = "Sc9EUQTZNEH-wzegn-nHvQ/TweetResultsByRestIds"
@ -47,6 +54,8 @@ const
"premium_content_api_read_enabled": false, "premium_content_api_read_enabled": false,
"communities_web_enable_tweet_community_results_fetch": true, "communities_web_enable_tweet_community_results_fetch": true,
"c9s_tweet_anatomy_moderator_badge_enabled": 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_button_fetch_trends_enabled": false,
"responsive_web_grok_analyze_post_followups_enabled": true, "responsive_web_grok_analyze_post_followups_enabled": true,
"rweb_cashtags_composer_attachment_enabled": true, "rweb_cashtags_composer_attachment_enabled": true,
@ -152,6 +161,34 @@ const
articleFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withArticleSummaryText":true,"withArticleVoiceOver":true}""" 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}""" userFieldToggles = """{"withPayments":false,"withAuxiliaryUserLabels":true}"""
userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}""" userTweetsFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false}"""
tweetDetailFieldToggles* = """{"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":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 types, config, prefs, formatters, redis_cache, http_pool, auth, apiutils
import views/[general, about] import views/[general, about]
import routes/[ 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] unsupported, embed, resolver, broadcast, article, router_utils]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
@ -54,6 +54,7 @@ createResolverRouter(cfg)
createPrefRouter(cfg) createPrefRouter(cfg)
createTimelineRouter(cfg) createTimelineRouter(cfg)
createListRouter(cfg) createListRouter(cfg)
createCommunityRouter(cfg)
createStatusRouter(cfg) createStatusRouter(cfg)
createSearchRouter(cfg) createSearchRouter(cfg)
createMediaRouter(cfg) createMediaRouter(cfg)
@ -126,6 +127,7 @@ routes:
extend timeline, "" extend timeline, ""
extend media, "" extend media, ""
extend list, "" extend list, ""
extend community, ""
extend preferences, "" extend preferences, ""
extend resolver, "" extend resolver, ""
extend embed, "" extend embed, ""

View file

@ -131,6 +131,36 @@ proc parseBroadcastInfo*(js: JsonNode): Broadcast =
user: parseGraphUser(bc) 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 = proc parseGraphList*(js: JsonNode): List =
if js.isNull: return if js.isNull: return
@ -855,3 +885,49 @@ proc parseGraphSearch*[T: User | Tweets](js: JsonNode; after=""): Result[T] =
elif typ == "TimelineReplaceEntry": elif typ == "TimelineReplaceEntry":
if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"):
result.bottom = instruction{"entry", "content", "value"}.getStr 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) result = await getPhotoRail(id)
await cache(result, 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.} = proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
let list = if id.len == 0: redisNil let list = if id.len == 0: redisNil
else: await get("l:" & id) 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 { .verified-icon {
display: inline-block; display: inline-block;
position: relative;
width: 14px; width: 14px;
height: 14px; height: 14px;
margin-bottom: 2px; margin-bottom: 2px;

View file

@ -4,6 +4,7 @@
@import "card"; @import "card";
@import "about-account"; @import "about-account";
@import "photo-rail"; @import "photo-rail";
@import "community";
.profile-tabs { .profile-tabs {
@include panel(auto, 900px); @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 members*: int
banner*: string 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 GlobalObjects* = ref object
tweets*: Table[string, Tweet] tweets*: Table[string, Tweet]
users*: Table[string, User] 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" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): 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") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
if theme.len > 0: if theme.len > 0:

View file

@ -18,7 +18,7 @@ proc getMediumPic*(url: string): string =
result &= mediumWebp result &= mediumWebp
result = getPicUrl(result) 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 var c = "icon-" & icon
if class.len > 0: c = &"{c} {class}" if class.len > 0: c = &"{c} {class}"
buildHtml(tdiv(class="icon-container")): buildHtml(tdiv(class="icon-container")):
@ -27,8 +27,8 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode =
else: else:
span(class=c, title=title) span(class=c, title=title)
if text.len > 0: if label.len > 0:
text " " & text text " " & label
template verifiedIcon*(user: User): untyped {.dirty.} = template verifiedIcon*(user: User): untyped {.dirty.} =
if user.verifiedType != VerifiedType.none: 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: renderThread(thread, prefs, path, bigThumb)
else: else:
for thread in filtered: 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) else: renderThread(thread, prefs, path)
var cursor = getSearchMaxId(results, path) var cursor = getSearchMaxId(results, path)

View file

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