mirror of
https://github.com/zedeus/nitter.git
synced 2026-04-16 10:42:08 -04:00
@@ -66,6 +66,13 @@ proc getGraphUserById*(id: string): Future[User] {.async.} =
|
|||||||
js = await fetchRaw(url)
|
js = await fetchRaw(url)
|
||||||
result = parseGraphUser(js)
|
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.} =
|
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let
|
let
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const
|
|||||||
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
graphListBySlug* = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"
|
||||||
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
graphListMembers* = "fuVHh5-gFn8zDBBxb8wOMA/ListMembers"
|
||||||
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
graphListTweets* = "VQf8_XQynI3WzH6xopOMMQ/ListTimeline"
|
||||||
|
graphAboutAccount* = "zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery"
|
||||||
|
|
||||||
gqlFeatures* = """{
|
gqlFeatures* = """{
|
||||||
"android_ad_formats_media_component_render_overlay_enabled": false,
|
"android_ad_formats_media_component_render_overlay_enabled": false,
|
||||||
|
|||||||
@@ -68,6 +68,48 @@ proc parseGraphUser(js: JsonNode): User =
|
|||||||
with verifiedType, user{"verification", "verified_type"}:
|
with verifiedType, user{"verification", "verified_type"}:
|
||||||
result.verifiedType = parseEnum[VerifiedType](verifiedType.getStr)
|
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 =
|
proc parseGraphList*(js: JsonNode): List =
|
||||||
if js.isNull: return
|
if js.isNull: return
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ proc getTimeFromMs*(js: JsonNode): DateTime =
|
|||||||
let seconds = ms div 1000
|
let seconds = ms div 1000
|
||||||
return fromUnix(seconds).utc()
|
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.} =
|
proc getId*(id: string): int64 {.inline.} =
|
||||||
let start = id.rfind("-")
|
let start = id.rfind("-")
|
||||||
if start < 0:
|
if start < 0:
|
||||||
|
|||||||
@@ -158,6 +158,19 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
|
|||||||
# if not result.isNil:
|
# if not result.isNil:
|
||||||
# await cache(result)
|
# 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.} =
|
proc getCachedPhotoRail*(id: string): Future[PhotoRail] {.async.} =
|
||||||
if id.len == 0: return
|
if id.len == 0: return
|
||||||
let rail = await get("pr2:" & toLower(id))
|
let rail = await get("pr2:" & toLower(id))
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import jester, karax/vdom
|
|||||||
|
|
||||||
import router_utils
|
import router_utils
|
||||||
import ".."/[types, redis_cache, formatters, query, api]
|
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 vdom
|
||||||
export uri, sequtils
|
export uri, sequtils
|
||||||
export router_utils
|
export router_utils
|
||||||
export redis_cache, formatters, query, api
|
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 =
|
proc getQuery*(request: Request; tab, name: string; prefs: Prefs): Query =
|
||||||
let view = request.params.getOrDefault("view")
|
let view = request.params.getOrDefault("view")
|
||||||
@@ -57,6 +57,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
|||||||
getCachedPhotoRail(userId)
|
getCachedPhotoRail(userId)
|
||||||
|
|
||||||
user = getCachedUser(name)
|
user = getCachedUser(name)
|
||||||
|
info = getCachedAccountInfo(name, fetch=false)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
case query.kind
|
case query.kind
|
||||||
@@ -67,6 +68,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false): Future[Profile]
|
|||||||
|
|
||||||
result.user = await user
|
result.user = await user
|
||||||
result.photoRail = await rail
|
result.photoRail = await rail
|
||||||
|
result.accountInfo = await info
|
||||||
|
|
||||||
result.tweets.query = query
|
result.tweets.query = query
|
||||||
|
|
||||||
@@ -119,6 +121,20 @@ proc createTimelineRouter*(cfg: Config) =
|
|||||||
resp Http400, showError("Missing screen_name parameter", cfg)
|
resp Http400, showError("Missing screen_name parameter", cfg)
|
||||||
redirect("/" & username)
|
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?/?":
|
get "/@name/?@tab?/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@import "_mixins";
|
@import "_mixins";
|
||||||
|
|
||||||
@import "card";
|
@import "card";
|
||||||
|
@import "about-account";
|
||||||
@import "photo-rail";
|
@import "photo-rail";
|
||||||
|
|
||||||
.profile-tabs {
|
.profile-tabs {
|
||||||
|
|||||||
71
src/sass/profile/about-account.scss
Normal file
71
src/sass/profile/about-account.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,23 @@ type
|
|||||||
suspended*: bool
|
suspended*: bool
|
||||||
joinDate*: DateTime
|
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
|
VideoType* = enum
|
||||||
m3u8 = "application/x-mpegURL"
|
m3u8 = "application/x-mpegURL"
|
||||||
mp4 = "video/mp4"
|
mp4 = "video/mp4"
|
||||||
@@ -273,6 +290,7 @@ type
|
|||||||
photoRail*: PhotoRail
|
photoRail*: PhotoRail
|
||||||
pinned*: Option[Tweet]
|
pinned*: Option[Tweet]
|
||||||
tweets*: Timeline
|
tweets*: Timeline
|
||||||
|
accountInfo*: AccountInfo
|
||||||
|
|
||||||
List* = object
|
List* = object
|
||||||
id*: string
|
id*: string
|
||||||
|
|||||||
93
src/views/about_account.nim
Normal file
93
src/views/about_account.nim
Normal file
@@ -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
|
||||||
@@ -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=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")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=5")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
|||||||
span(class="profile-stat-num"):
|
span(class="profile-stat-num"):
|
||||||
text insertSep($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")):
|
buildHtml(tdiv(class="profile-card")):
|
||||||
tdiv(class="profile-card-info"):
|
tdiv(class="profile-card-info"):
|
||||||
let
|
let
|
||||||
@@ -46,6 +46,11 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||||||
else:
|
else:
|
||||||
span: text place
|
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:
|
if user.website.len > 0:
|
||||||
tdiv(class="profile-website"):
|
tdiv(class="profile-website"):
|
||||||
span:
|
span:
|
||||||
@@ -54,7 +59,7 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
|||||||
a(href=url): text url.shortLink
|
a(href=url): text url.shortLink
|
||||||
|
|
||||||
tdiv(class="profile-joindate"):
|
tdiv(class="profile-joindate"):
|
||||||
span(title=getJoinDateFull(user)):
|
a(href=(&"/{user.username}/about"), title=getJoinDateFull(user)):
|
||||||
icon "calendar", getJoinDate(user)
|
icon "calendar", getJoinDate(user)
|
||||||
|
|
||||||
tdiv(class="profile-card-extra-links"):
|
tdiv(class="profile-card-extra-links"):
|
||||||
@@ -115,7 +120,7 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
|||||||
if not isGalleryView:
|
if not isGalleryView:
|
||||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
tdiv(class=("profile-tab" & sticky)):
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
renderUserCard(profile.user, prefs)
|
renderUserCard(profile.user, prefs, profile.accountInfo)
|
||||||
if profile.photoRail.len > 0:
|
if profile.photoRail.len > 0:
|
||||||
renderPhotoRail(profile)
|
renderPhotoRail(profile)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user