mirror of
https://github.com/zedeus/nitter
synced 2026-09-05 14:49:32 +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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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}"""
|
||||
|
|
|
|||
|
|
@ -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, ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -195,6 +195,29 @@ proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
|||
result = await getPhotoRail(id)
|
||||
await cache(result, id)
|
||||
|
||||
proc cache*(data: Community) {.async.} =
|
||||
if data.id.len == 0: return
|
||||
await setEx("cm:" & data.id, listCacheTime, compress(toFlatty(data)))
|
||||
|
||||
proc getCachedCommunity*(id: string): Future[Community] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("cm:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(Community)
|
||||
else:
|
||||
result = await getGraphCommunity(id)
|
||||
await cache(result)
|
||||
|
||||
proc getCachedCommunityModerators*(id: string): Future[seq[User]] {.async.} =
|
||||
if id.len == 0: return
|
||||
let cached = await get("cmm:" & id)
|
||||
if cached != redisNil:
|
||||
cached.deserialize(seq[User])
|
||||
else:
|
||||
let mods = await getGraphCommunityModerators(id)
|
||||
result = mods.content
|
||||
await setEx("cmm:" & id, listCacheTime, compress(toFlatty(result)))
|
||||
|
||||
proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} =
|
||||
let list = if id.len == 0: redisNil
|
||||
else: await get("l:" & id)
|
||||
|
|
|
|||
89
src/routes/community.nim
Normal file
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 {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
@import "card";
|
||||
@import "about-account";
|
||||
@import "photo-rail";
|
||||
@import "community";
|
||||
|
||||
.profile-tabs {
|
||||
@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
|
||||
banner*: string
|
||||
|
||||
CommunityRule* = object
|
||||
name*: string
|
||||
description*: string
|
||||
|
||||
Community* = object
|
||||
id*: string
|
||||
name*: string
|
||||
description*: string
|
||||
memberCount*: int
|
||||
banner*: string
|
||||
creator*: User
|
||||
category*: string
|
||||
joinPolicy*: string
|
||||
createdAt*: DateTime
|
||||
rules*: seq[CommunityRule]
|
||||
hashtags*: seq[string]
|
||||
|
||||
GlobalObjects* = ref object
|
||||
tweets*: Table[string, Tweet]
|
||||
users*: Table[string, User]
|
||||
|
|
|
|||
128
src/views/community.nim
Normal file
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"
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -27,14 +27,18 @@ proc renderArticleCard(preview: ArticlePreview; prefs: Prefs): VNode =
|
|||
if preview.previewText.len > 0:
|
||||
p(class="card-description"): text preview.previewText
|
||||
|
||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs): VNode =
|
||||
proc renderHeader(tweet: Tweet; retweet: string; pinned: bool; prefs: Prefs;
|
||||
path = ""): VNode =
|
||||
buildHtml(tdiv):
|
||||
if pinned:
|
||||
let pinnedLabel =
|
||||
if "/i/communities/" in path: "Pinned by Community mods"
|
||||
else: "Pinned Tweet"
|
||||
tdiv(class="pinned"):
|
||||
span: icon "pin", "Pinned Tweet"
|
||||
span: icon("pin", pinnedLabel)
|
||||
elif retweet.len > 0:
|
||||
tdiv(class="retweet-header"):
|
||||
span: icon "retweet", retweet & " retweeted"
|
||||
span: icon("retweet", retweet & " retweeted")
|
||||
|
||||
tdiv(class="tweet-header"):
|
||||
a(class="tweet-avatar", href=("/" & tweet.user.username)):
|
||||
|
|
@ -358,7 +362,8 @@ proc renderLocation*(tweet: Tweet): string =
|
|||
return $node
|
||||
|
||||
proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
||||
last=false; mainTweet=false; afterTweet=false; bigThumb=false): VNode =
|
||||
last=false; mainTweet=false; afterTweet=false;
|
||||
bigThumb=false): VNode =
|
||||
var divClass = class
|
||||
if index == -1 or last:
|
||||
divClass = "thread-last " & class
|
||||
|
|
@ -391,7 +396,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||
a(class="tweet-link", href=getLink(tweet))
|
||||
|
||||
tdiv(class="tweet-body"):
|
||||
renderHeader(tweet, retweet, pinned, prefs)
|
||||
renderHeader(tweet, retweet, pinned, prefs, path)
|
||||
|
||||
if not afterTweet and index == 0 and tweet.reply.len > 0 and
|
||||
(tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username or pinned):
|
||||
|
|
|
|||
0
temp
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