Bladeren bron

Add support for Communities

Fixes #1270
Zed 2 maanden geleden
bovenliggende
commit
7b27c2c629

+ 51 - 0
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

+ 37 - 0
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}"""

+ 3 - 1
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, ""

+ 76 - 0
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
+

+ 23 - 0
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)

+ 89 - 0
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))

+ 1 - 0
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;

+ 1 - 0
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);

+ 203 - 0
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;
+  }
+}

+ 17 - 0
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]

+ 128 - 0
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

+ 1 - 1
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:

+ 3 - 3
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:

+ 2 - 1
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)

+ 10 - 5
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):

+ 0 - 0
temp


+ 211 - 0
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')