diff --git a/src/api.nim b/src/api.nim index 9e496a4..2bd1e3b 100644 --- a/src/api.nim +++ b/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 diff --git a/src/consts.nim b/src/consts.nim index c81443c..981f991 100644 --- a/src/consts.nim +++ b/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, diff --git a/src/parser.nim b/src/parser.nim index c7a6e76..70cf4a7 100644 --- a/src/parser.nim +++ b/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 diff --git a/src/parserutils.nim b/src/parserutils.nim index 518724e..8d6ea2e 100644 --- a/src/parserutils.nim +++ b/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: diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 559d299..0ed15ce 100644 --- a/src/redis_cache.nim +++ b/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)) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8a11e0e..0eb48e2 100644 --- a/src/routes/timeline.nim +++ b/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"] diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 4daddf6..2482460 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -2,6 +2,7 @@ @import "_mixins"; @import "card"; +@import "about-account"; @import "photo-rail"; .profile-tabs { diff --git a/src/sass/profile/about-account.scss b/src/sass/profile/about-account.scss new file mode 100644 index 0000000..aa12f49 --- /dev/null +++ b/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; + } +} diff --git a/src/types.nim b/src/types.nim index 7f98b9d..9c34722 100644 --- a/src/types.nim +++ b/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 diff --git a/src/views/about_account.nim b/src/views/about_account.nim new file mode 100644 index 0000000..aedd444 --- /dev/null +++ b/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 diff --git a/src/views/general.nim b/src/views/general.nim index d7af7e8..f839eb8 100644 --- a/src/views/general.nim +++ b/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: diff --git a/src/views/profile.nim b/src/views/profile.nim index 83d300a..5b751d6 100644 --- a/src/views/profile.nim +++ b/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)