mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 22:59:31 +00:00
parent
35882ed88d
commit
7b27c2c629
17 changed files with 856 additions and 11 deletions
51
src/api.nim
51
src/api.nim
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"""
|
||||||
|
|
|
||||||
|
|
@ -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, ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
89
src/routes/community.nim
Normal 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))
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
203
src/sass/profile/_community.scss
Normal file
203
src/sass/profile/_community.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
128
src/views/community.nim
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
0
temp
211
tests/test_community.py
Normal file
211
tests/test_community.py
Normal 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')
|
||||||
Loading…
Reference in a new issue