From 7b27c2c629a55661ad5b4ad91fb1a7b2d3f9910e Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 18 Jun 2026 13:32:20 +0200 Subject: [PATCH] Add support for Communities Fixes #1270 --- src/api.nim | 51 ++++++++ src/consts.nim | 37 ++++++ src/nitter.nim | 4 +- src/parser.nim | 76 +++++++++++ src/redis_cache.nim | 23 ++++ src/routes/community.nim | 89 +++++++++++++ src/sass/index.scss | 1 + src/sass/profile/_base.scss | 1 + src/sass/profile/_community.scss | 203 +++++++++++++++++++++++++++++ src/types.nim | 17 +++ src/views/community.nim | 128 +++++++++++++++++++ src/views/general.nim | 2 +- src/views/renderutils.nim | 6 +- src/views/timeline.nim | 3 +- src/views/tweet.nim | 15 ++- temp | 0 tests/test_community.py | 211 +++++++++++++++++++++++++++++++ 17 files changed, 856 insertions(+), 11 deletions(-) create mode 100644 src/routes/community.nim create mode 100644 src/sass/profile/_community.scss create mode 100644 src/views/community.nim delete mode 100644 temp create mode 100644 tests/test_community.py diff --git a/src/api.nim b/src/api.nim index 2702f7c..9aa6c50 100644 --- a/src/api.nim +++ b/src/api.nim @@ -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 diff --git a/src/consts.nim b/src/consts.nim index 5ac3c32..59b986c 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -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}""" diff --git a/src/nitter.nim b/src/nitter.nim index 78cace4..955849b 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -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, "" diff --git a/src/parser.nim b/src/parser.nim index d7b3f8d..c810f76 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -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 + diff --git a/src/redis_cache.nim b/src/redis_cache.nim index e503e46..4d5bf28 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -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) diff --git a/src/routes/community.nim b/src/routes/community.nim new file mode 100644 index 0000000..b850b6b --- /dev/null +++ b/src/routes/community.nim @@ -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)) diff --git a/src/sass/index.scss b/src/sass/index.scss index 8126fe9..b8c9a8c 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -160,6 +160,7 @@ body.fixed-nav .container { .verified-icon { display: inline-block; + position: relative; width: 14px; height: 14px; margin-bottom: 2px; diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 2482460..81b3d78 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -4,6 +4,7 @@ @import "card"; @import "about-account"; @import "photo-rail"; +@import "community"; .profile-tabs { @include panel(auto, 900px); diff --git a/src/sass/profile/_community.scss b/src/sass/profile/_community.scss new file mode 100644 index 0000000..92d13c9 --- /dev/null +++ b/src/sass/profile/_community.scss @@ -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; + } +} diff --git a/src/types.nim b/src/types.nim index 05b7fb3..79a99c0 100644 --- a/src/types.nim +++ b/src/types.nim @@ -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] diff --git a/src/views/community.nim b/src/views/community.nim new file mode 100644 index 0000000..52f9041 --- /dev/null +++ b/src/views/community.nim @@ -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 diff --git a/src/views/general.nim b/src/views/general.nim index 13ac10d..2e515bb 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -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: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 0bd9789..8774f9d 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -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: diff --git a/src/views/timeline.nim b/src/views/timeline.nim index b911765..9456bf9 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -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) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 9bf6c9b..e658c8e 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -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): diff --git a/temp b/temp deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_community.py b/tests/test_community.py new file mode 100644 index 0000000..b266044 --- /dev/null +++ b/tests/test_community.py @@ -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')