Explorar el Código

Add support for viewing account info

Fixes #1381
Zed hace 5 meses
padre
commit
e7e7050c6e

+ 7 - 0
src/api.nim

@@ -66,6 +66,13 @@ proc getGraphUserById*(id: string): Future[User] {.async.} =
     js = await fetchRaw(url)
   result = parseGraphUser(js)
 
+proc getAboutAccount*(username: string): Future[AccountInfo] {.async.} =
+  if username.len == 0: return
+  let
+    url = apiReq(graphAboutAccount, """{"screenName":"$1"}""" % username)
+    js = await fetch(url)
+  result = parseAboutAccount(js)
+
 proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
   if id.len == 0: return
   let

+ 1 - 0
src/consts.nim

@@ -25,6 +25,7 @@ const
   graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
   graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
   graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
+  graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
 
   gqlFeatures* = """{
   "android_ad_formats_media_component_render_overlay_enabled": false,

+ 42 - 0
src/parser.nim

@@ -68,6 +68,48 @@ proc parseGraphUser(js: JsonNode): User =
     with verifiedType, user{"verification", "verified_type"}:
       result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
 
+proc parseAboutAccount*(js: JsonNode): AccountInfo =
+  if js.isNull: return
+
+  let user = ? js{"data", "user_result_by_screen_name", "result"}
+
+  if user{"unavailable_reason"}.getStr == "Suspended":
+    result.suspended = true
+    return
+
+  result = AccountInfo(
+    username: user{"core", "screen_name"}.getStr,
+    fullname: user{"core", "name"}.getStr,
+    joinDate: user{"core", "created_at"}.getTime,
+    userPic: user{"avatar", "image_url"}.getImageStr.replace("_normal", ""),
+    affiliateLabel: user{"identity_profile_labels_highlighted_label", "label", "description"}.getStr,
+  )
+
+  if user{"is_blue_verified"}.getBool(false):
+    result.verifiedType = blue
+  with verifiedType, user{"verification", "verified_type"}:
+    result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
+
+  with about, user{"about_profile"}:
+    result.basedIn = about{"account_based_in"}.getStr
+    result.source = about{"source"}.getStr
+    result.affiliateUsername = about{"affiliate_username"}.getStr
+
+    try: 
+      result.usernameChanges = about{"username_changes", "count"}.getStr("0").parseInt
+    except ValueError: 
+      discard
+
+    with lastChange, about{"username_changes", "last_changed_at_msec"}:
+      result.lastUsernameChange = lastChange.getTimeFromMsStr
+
+  with info, user{"verification_info"}:
+    result.isIdentityVerified = info{"is_identity_verified"}.getBool
+    with reason, info{"reason"}:
+      result.overrideVerifiedYear = reason{"override_verified_year"}.getInt
+      with since, reason{"verified_since_msec"}:
+        result.verifiedSince = since.getTimeFromMsStr
+
 proc parseGraphList*(js: JsonNode): List =
   if js.isNull: return
 

+ 8 - 0
src/parserutils.nim

@@ -88,6 +88,14 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
   let seconds = ms div 1000
   return fromUnix(seconds).utc()
 
+proc getTimeFromMsStr*(js: JsonNode): DateTime =
+  var ms: int64
+  try: ms = parseBiggestInt(js.getStr("0"))
+  except ValueError: return
+  if ms == 0: return
+  let seconds = ms div 1000
+  return fromUnix(seconds).utc()
+
 proc getId*(id: string): int64 {.inline.} =
   let start = id.rfind("-")
   if start < 0:

+ 13 - 0
src/redis_cache.nim

@@ -158,6 +158,19 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
 #     if not result.isNil:
 #       await cache(result)
 
+proc cache*(data: AccountInfo; name: string) {.async.} =
+  await setEx("ai:" & toLower(name), baseCacheTime * 24, compress(toFlatty(data)))
+
+proc getCachedAccountInfo*(username: string; fetch=true): Future[AccountInfo] {.async.} =
+  if username.len == 0: return
+  let name = toLower(username)
+  let cached = await get("ai:" & name)
+  if cached != redisNil:
+    cached.deserialize(AccountInfo)
+  elif fetch:
+    result = await getAboutAccount(username)
+    await cache(result, name)
+
 proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
   if id.len == 0: return
   let rail = await get("pr2:" & toLower(id))

+ 18 - 2
src/routes/timeline.nim

@@ -4,13 +4,13 @@ import jester, karax/vdom
 
 import router_utils
 import ".."/[types, redis_cache, formatters, query, api]
-import ../views/[general, profile, timeline, status, search]
+import ../views/[general, profile, timeline, status, search, about_account]
 
 export vdom
 export uri, sequtils
 export router_utils
 export redis_cache, formatters, query, api
-export profile, timeline, status
+export profile, timeline, status, about_account
 
 proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
   let view = request.params.getOrDefault("view")
@@ -57,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
         getCachedPhotoRail(userId)
 
     user = getCachedUser(name)
+    info = getCachedAccountInfo(name, fetch=false)
 
   result =
     case query.kind
@@ -67,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
 
   result.user = await user
   result.photoRail = await rail
+  result.accountInfo = await info
 
   result.tweets.query = query
 
@@ -119,6 +121,20 @@ proc createTimelineRouter*(cfg: Config) =
         resp Http400, showError("Missing screen_name parameter", cfg)
       redirect("/" & username)
 
+    get "/@name/about/?":
+      cond @"name".allCharsInSet({'a'..'z', 'A'..'Z', '0'..'9', '_'})
+      let
+        prefs = requestPrefs()
+        name = @"name"
+        info = await getCachedAccountInfo(name)
+      if info.suspended:
+        resp showError(getSuspended(name), cfg)
+      if info.username.len == 0:
+        resp Http404, showError("User \"" & name & "\" not found", cfg)
+      let aboutHtml = renderAboutAccount(info)
+      resp renderMain(aboutHtml, request, cfg, prefs,
+                      "About @" & info.username)
+
     get "/@name/?@tab?/?":
       cond '.' notin @"name"
       cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]

+ 1 - 0
src/sass/profile/_base.scss

@@ -2,6 +2,7 @@
 @import "_mixins";
 
 @import "card";
+@import "about-account";
 @import "photo-rail";
 
 .profile-tabs {

+ 71 - 0
src/sass/profile/about-account.scss

@@ -0,0 +1,71 @@
+@import '_variables';
+
+.about-account {
+    max-width: 500px;
+    width: 100%;
+    margin: 20px auto 0;
+    align-self: flex-start;
+    background: var(--bg_panel);
+    border-radius: 4px;
+    padding: 12px 20px 20px;
+}
+
+.about-account-header {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin-bottom: 16px;
+    padding-bottom: 14px;
+    border-bottom: 1px solid var(--border_grey);
+}
+
+.about-account-avatar img {
+    width: 72px;
+    height: 72px;
+    border-radius: 50%;
+    margin-bottom: 4px;
+}
+
+.about-account-name {
+    @include breakable;
+    font-weight: bold;
+}
+
+.about-account-body {
+    display: flex;
+    flex-direction: column;
+    gap: 14px;
+}
+
+.about-account-at {
+    font-size: 18px;
+    font-weight: bold;
+}
+
+.about-account-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+
+    > span:first-child {
+        color: var(--fg_faded);
+        flex-shrink: 0;
+    }
+
+    > div {
+        display: flex;
+        flex-direction: column;
+    }
+}
+
+.about-account-label {
+    color: var(--fg_faded);
+    font-size: 13px;
+}
+
+@media(max-width: 700px) {
+    .about-account {
+        max-width: none;
+        margin: 10px;
+    }
+}

+ 18 - 0
src/types.nim

@@ -96,6 +96,23 @@ type
     suspended*: bool
     joinDate*: DateTime
 
+  AccountInfo* = object
+    username*: string
+    fullname*: string
+    userPic*: string
+    joinDate*: DateTime
+    verifiedType*: VerifiedType
+    suspended*: bool
+    basedIn*: string
+    source*: string
+    usernameChanges*: int
+    lastUsernameChange*: DateTime
+    affiliateUsername*: string
+    affiliateLabel*: string
+    isIdentityVerified*: bool
+    verifiedSince*: DateTime
+    overrideVerifiedYear*: int
+
   VideoType* = enum
     m3u8 = "application/x-mpegURL"
     mp4 = "video/mp4"
@@ -273,6 +290,7 @@ type
     photoRail*: PhotoRail
     pinned*: Option[Tweet]
     tweets*: Timeline
+    accountInfo*: AccountInfo
 
   List* = object
     id*: string

+ 93 - 0
src/views/about_account.nim

@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+import strutils, strformat, times
+import karax/[karaxdsl, vdom]
+
+import renderutils
+import ".."/[types, formatters]
+
+proc renderAboutAccount*(info: AccountInfo): VNode =
+  let user = User(
+    username: info.username,
+    fullname: info.fullname,
+    userPic: info.userPic,
+    verifiedType: info.verifiedType
+  )
+
+  buildHtml(tdiv(class="about-account")):
+    tdiv(class="about-account-header"):
+      a(class="about-account-avatar", href=(&"/{info.username}")):
+        genImg(getUserPic(info.userPic, "_200x200"))
+      tdiv(class="about-account-name"):
+        linkUser(user, class="profile-card-fullname")
+        verifiedIcon(user)
+      linkUser(user, class="profile-card-username")
+
+    tdiv(class="about-account-body"):
+      tdiv(class="about-account-row"):
+        span: icon "calendar"
+        tdiv:
+          span(class="about-account-label"): text "Date joined"
+          span(class="about-account-value"):
+            text info.joinDate.format("MMMM YYYY")
+
+      if info.basedIn.len > 0:
+        tdiv(class="about-account-row"):
+          span: icon "location"
+          tdiv:
+            span(class="about-account-label"): text "Account based in"
+            span(class="about-account-value"): text info.basedIn
+
+      if info.verifiedType != VerifiedType.none:
+        if info.overrideVerifiedYear != 0:
+          tdiv(class="about-account-row"):
+            span: icon "ok"
+            tdiv:
+              span(class="about-account-label"): text "Verified"
+              span(class="about-account-value"):
+                let year = abs(info.overrideVerifiedYear)
+                let era = if info.overrideVerifiedYear < 0: " BCE" else: ""
+                text "Since " & $year & era
+        elif info.verifiedSince.year > 0:
+          tdiv(class="about-account-row"):
+            span: icon "ok"
+            tdiv:
+              span(class="about-account-label"): text "Verified"
+              span(class="about-account-value"):
+                text "Since " & info.verifiedSince.format("MMMM YYYY")
+
+        if info.isIdentityVerified:
+          tdiv(class="about-account-row"):
+            span: icon "ok"
+            tdiv:
+              span(class="about-account-label"): text "ID Verified"
+              span(class="about-account-value"): text "Yes"
+
+      if info.affiliateUsername.len > 0:
+        tdiv(class="about-account-row"):
+          span: icon "group"
+          tdiv:
+            span(class="about-account-label"): text "An affiliate of"
+            span(class="about-account-value"):
+              a(href=(&"/{info.affiliateUsername}")):
+                if info.affiliateLabel.len > 0:
+                  text info.affiliateLabel & " (@" & info.affiliateUsername & ")"
+                else:
+                  text "@" & info.affiliateUsername
+
+      if info.usernameChanges > 0:
+        tdiv(class="about-account-row"):
+          span(class="about-account-at"): text "@"
+          tdiv:
+            span(class="about-account-label"):
+              text $info.usernameChanges & " username change"
+              if info.usernameChanges > 1: text "s"
+            if info.lastUsernameChange.year > 0:
+              span(class="about-account-value"):
+                text "Last on " & info.lastUsernameChange.format("MMMM YYYY")
+
+      if info.source.len > 0:
+        tdiv(class="about-account-row"):
+          span: icon "link"
+          tdiv:
+            span(class="about-account-label"): text "Connected via"
+            span(class="about-account-value"): text info.source

+ 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=32")
+    link(rel="stylesheet", type="text/css", href="/css/style.css?v=34")
     link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
 
     if theme.len > 0:

+ 8 - 3
src/views/profile.nim

@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
     span(class="profile-stat-num"):
       text insertSep($num, ',')
 
-proc renderUserCard*(user: User; prefs: Prefs): VNode =
+proc renderUserCard*(user: User; prefs: Prefs; info: AccountInfo): VNode =
   buildHtml(tdiv(class="profile-card")):
     tdiv(class="profile-card-info"):
       let
@@ -46,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
           else:
             span: text place
 
+      if info.basedIn.len > 0:
+        tdiv(class="profile-location"):
+          span: icon "location"
+          span: text "Based in " & info.basedIn
+
       if user.website.len > 0:
         tdiv(class="profile-website"):
           span:
@@ -54,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
             a(href=url): text url.shortLink
 
       tdiv(class="profile-joindate"):
-        span(title=getJoinDateFull(user)):
+        a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
           icon "calendar", getJoinDate(user)
 
       tdiv(class="profile-card-extra-links"):
@@ -115,7 +120,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
     if not isGalleryView:
       let sticky = if prefs.stickyProfile: " sticky" else: ""
       tdiv(class=("profile-tab" & sticky)):
-        renderUserCard(profile.user, prefs)
+        renderUserCard(profile.user, prefs, profile.accountInfo)
         if profile.photoRail.len > 0:
           renderPhotoRail(profile)