From 51ae076ea0ceb217bd8c056e72bb091954955982 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 23 Jan 2022 07:04:50 +0100 Subject: [PATCH] Rearchitect profile, support pins, Profile -> User --- src/api.nim | 45 ++++++++++---- src/consts.nim | 7 ++- src/experimental/parser/user.nim | 28 ++++----- src/experimental/types/user.nim | 2 +- src/formatters.nim | 30 ++++----- src/parser.nim | 95 +++++++++++++++++----------- src/parserutils.nim | 22 +++++-- src/redis_cache.nim | 79 ++++++++++++++++-------- src/routes/list.nim | 2 +- src/routes/rss.nim | 26 ++++---- src/routes/search.nim | 2 +- src/routes/status.nim | 7 ++- src/routes/timeline.nim | 102 +++++++++++++++---------------- src/tokens.nim | 2 +- src/types.nim | 21 ++++--- src/views/profile.nim | 74 +++++++++++----------- src/views/renderutils.nim | 12 ++-- src/views/rss.nimf | 26 ++++---- src/views/search.nim | 9 +-- src/views/status.nim | 16 +++-- src/views/timeline.nim | 11 +++- src/views/tweet.nim | 36 +++++------ tests/test_profile.py | 5 +- 23 files changed, 374 insertions(+), 285 deletions(-) diff --git a/src/api.nim b/src/api.nim index b1ecc57..eab5b3c 100644 --- a/src/api.nim +++ b/src/api.nim @@ -1,9 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, httpclient, uri, strutils +import asyncdispatch, httpclient, uri, strutils, sequtils, sugar import packedjson import types, query, formatters, consts, apiutils, parser import experimental/parser/user +proc getGraphUser*(id: string): Future[User] {.async.} = + if id.len == 0 or id.any(c => not c.isDigit): return + let + variables = %*{"userId": id, "withSuperFollowsUserFields": true} + js = await fetch(graphUser ? {"variables": $variables}, Api.userRestId) + result = parseGraphUser(js, id) + proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list, "withHighlightedLabel": false} @@ -16,6 +23,22 @@ proc getGraphList*(id: string): Future[List] {.async.} = url = graphList ? {"variables": $variables} result = parseGraphList(await fetch(url, Api.list)) +proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = + if list.id.len == 0: return + let + variables = %*{ + "listId": list.id, + "cursor": after, + "withSuperFollowsUserFields": false, + "withBirdwatchPivots": false, + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false, + "withSuperFollowsTweetFields": false + } + url = graphListMembers ? {"variables": $variables} + result = parseGraphListMembers(await fetch(url, Api.listMembers), after) + proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let @@ -23,44 +46,42 @@ proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = url = listTimeline ? ps result = parseTimeline(await fetch(url, Api.timeline), after) -proc getListMembers*(list: List; after=""): Future[Result[Profile]] {.async.} = - if list.id.len == 0: return - let - ps = genParams({"list_id": list.id}, after) - url = listMembers ? ps - result = parseListMembers(await fetch(url, Api.listMembers), after) - -proc getProfile*(username: string): Future[Profile] {.async.} = +proc getUser*(username: string): Future[User] {.async.} = + if username.len == 0: return let ps = genParams({"screen_name": username}) json = await fetchRaw(userShow ? ps, Api.userShow) result = parseUser(json, username) -proc getProfileById*(userId: string): Future[Profile] {.async.} = +proc getUserById*(userId: string): Future[User] {.async.} = + if userId.len == 0: return let ps = genParams({"user_id": userId}) json = await fetchRaw(userShow ? ps, Api.userShow) result = parseUser(json) proc getTimeline*(id: string; after=""; replies=false): Future[Timeline] {.async.} = + if id.len == 0: return let ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) url = timeline / (id & ".json") ? ps result = parseTimeline(await fetch(url, Api.timeline), after) proc getMediaTimeline*(id: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return let url = mediaTimeline / (id & ".json") ? genParams(cursor=after) result = parseTimeline(await fetch(url, Api.timeline), after) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = + if name.len == 0: return let ps = genParams({"screen_name": name, "trim_user": "true"}, count="18", ext=false) url = photoRail ? ps - result = parsePhotoRail(await fetch(url, Api.photoRail)) + result = parsePhotoRail(await fetch(url, Api.timeline)) proc getSearch*[T](query: Query; after=""): Future[Result[T]] {.async.} = - when T is Profile: + when T is User: const searchMode = ("result_filter", "user") parse = parseUsers diff --git a/src/consts.nim b/src/consts.nim index c77ebef..4093082 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -2,12 +2,11 @@ import uri, sequtils const - auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw" api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") - listMembers* = api / "1.1/lists/members.json" userShow* = api / "1.1/users/show.json" photoRail* = api / "1.1/statuses/media_timeline.json" search* = api / "2/search/adaptive.json" @@ -19,8 +18,10 @@ const tweet* = timelineApi / "conversation" graphql = api / "graphql" - graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug" + graphUser* = graphql / "I5nvpI91ljifos1Y3Lltyg/UserByRestId" graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List" + graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug" + graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers" timelineParams* = { "include_profile_interstitial_type": "0", diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index de55ed8..095d25c 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -2,7 +2,7 @@ import std/[algorithm, unicode, re, strutils] import jsony import utils, slices import ../types/user as userType -from ../../types import Profile, Error +from ../../types import User, Error let unRegex = re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" @@ -11,13 +11,13 @@ let htRegex = re"(^|[^\w-_./?])([##$])([\w_]+)" htReplace = "$1$2$3" -proc expandProfileEntities(profile: var Profile; user: User) = +proc expandUserEntities(user: var User; raw: RawUser) = let - orig = profile.bio.toRunes - ent = user.entities + orig = user.bio.toRunes + ent = raw.entities if ent.url.urls.len > 0: - profile.website = ent.url.urls[0].expandedUrl + user.website = ent.url.urls[0].expandedUrl var replacements = newSeq[ReplaceSlice]() @@ -27,26 +27,26 @@ proc expandProfileEntities(profile: var Profile; user: User) = replacements.dedupSlices replacements.sort(cmp) - profile.bio = orig.replacedWith(replacements, 0 .. orig.len) - .replacef(unRegex, unReplace) - .replacef(htRegex, htReplace) + user.bio = orig.replacedWith(replacements, 0 .. orig.len) + .replacef(unRegex, unReplace) + .replacef(htRegex, htReplace) -proc getBanner(user: User): string = +proc getBanner(user: RawUser): string = if user.profileBannerUrl.len > 0: return user.profileBannerUrl & "/1500x500" if user.profileLinkColor.len > 0: return '#' & user.profileLinkColor -proc parseUser*(json: string; username=""): Profile = +proc parseUser*(json: string; username=""): User = handleErrors: case error.code - of suspended: return Profile(username: username, suspended: true) + of suspended: return User(username: username, suspended: true) of userNotFound: return else: echo "[error - parseUser]: ", error - let user = json.fromJson(User) + let user = json.fromJson(RawUser) - result = Profile( + result = User( id: user.idStr, username: user.screenName, fullname: user.name, @@ -64,4 +64,4 @@ proc parseUser*(json: string; username=""): Profile = userPic: getImageUrl(user.profileImageUrlHttps).replace("_normal", "") ) - result.expandProfileEntities(user) + result.expandUserEntities(user) diff --git a/src/experimental/types/user.nim b/src/experimental/types/user.nim index 1f31318..e3afaf0 100644 --- a/src/experimental/types/user.nim +++ b/src/experimental/types/user.nim @@ -1,7 +1,7 @@ import common type - User* = object + RawUser* = object idStr*: string name*: string screenName*: string diff --git a/src/formatters.nim b/src/formatters.nim index 2ae3077..bb8698c 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -97,29 +97,29 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = proc getUserPic*(userPic: string; style=""): string = userPic.replacef(userPicRegex, "$2").replacef(extRegex, style & "$1") -proc getUserPic*(profile: Profile; style=""): string = - getUserPic(profile.userPic, style) +proc getUserPic*(user: User; style=""): string = + getUserPic(user.userPic, style) proc getVideoEmbed*(cfg: Config; id: int64): string = &"{getUrlPrefix(cfg)}/i/videos/{id}" -proc pageTitle*(profile: Profile): string = - &"{profile.fullname} (@{profile.username})" +proc pageTitle*(user: User): string = + &"{user.fullname} (@{user.username})" proc pageTitle*(tweet: Tweet): string = - &"{pageTitle(tweet.profile)}: \"{stripHtml(tweet.text)}\"" + &"{pageTitle(tweet.user)}: \"{stripHtml(tweet.text)}\"" -proc pageDesc*(profile: Profile): string = - if profile.bio.len > 0: - stripHtml(profile.bio) +proc pageDesc*(user: User): string = + if user.bio.len > 0: + stripHtml(user.bio) else: - "The latest tweets from " & profile.fullname + "The latest tweets from " & user.fullname -proc getJoinDate*(profile: Profile): string = - profile.joinDate.format("'Joined' MMMM YYYY") +proc getJoinDate*(user: User): string = + user.joinDate.format("'Joined' MMMM YYYY") -proc getJoinDateFull*(profile: Profile): string = - profile.joinDate.format("h:mm tt - d MMM YYYY") +proc getJoinDateFull*(user: User): string = + user.joinDate.format("h:mm tt - d MMM YYYY") proc getTime*(tweet: Tweet): string = tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") @@ -146,7 +146,7 @@ proc getShortTime*(tweet: Tweet): string = proc getLink*(tweet: Tweet; focus=true): string = if tweet.id == 0: return - var username = tweet.profile.username + var username = tweet.user.username if username.len == 0: username = "i" result = &"/{username}/status/{tweet.id}" @@ -175,7 +175,7 @@ proc getTwitterLink*(path: string; params: Table[string, string]): string = if username.len > 0: result = result.replace("/" & username, "") -proc getLocation*(u: Profile | Tweet): (string, string) = +proc getLocation*(u: User | Tweet): (string, string) = if "://" in u.location: return (u.location, "") let loc = u.location.split(":") let url = if loc.len > 1: "/search?q=place:" & loc[1] else: "" diff --git a/src/parser.nim b/src/parser.nim index 0991091..508602d 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -4,9 +4,9 @@ import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard -proc parseProfile(js: JsonNode; id=""): Profile = +proc parseUser(js: JsonNode; id=""): User = if js.isNull: return - result = Profile( + result = User( id: if id.len > 0: id else: js{"id_str"}.getStr, username: js{"screen_name"}.getStr, fullname: js{"name"}.getStr, @@ -24,7 +24,17 @@ proc parseProfile(js: JsonNode; id=""): Profile = joinDate: js{"created_at"}.getTime ) - result.expandProfileEntities(js) + result.expandUserEntities(js) + +proc parseGraphUser*(js: JsonNode; id: string): User = + if js.isNull: return + + with user, js{"data", "user", "result", "legacy"}: + result = parseUser(user, id) + + with pinned, user{"pinned_tweet_ids_str"}: + if pinned.kind == JArray and pinned.len > 0: + result.pinnedTweet = parseBiggestInt(pinned[0].getStr) proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -45,21 +55,30 @@ proc parseGraphList*(js: JsonNode): List = banner: list{"custom_banner_media", "media_info", "url"}.getImageStr ) -proc parseListMembers*(js: JsonNode; cursor: string): Result[Profile] = - result = Result[Profile]( +proc parseGraphListMembers*(js: JsonNode; cursor: string): Result[User] = + result = Result[User]( beginning: cursor.len == 0, query: Query(kind: userList) ) if js.isNull: return - result.top = js{"previous_cursor_str"}.getStr - result.bottom = js{"next_cursor_str"}.getStr - if result.bottom.len == 1: - result.bottom.setLen 0 + # result.top = js{"previous_cursor_str"}.getStr + # result.bottom = js{"next_cursor_str"}.getStr + # if result.bottom.len == 1: + # result.bottom.setLen 0 + + let root = js{"data", "list", "members_timeline", "timeline", "instructions"} + for instruction in root: + if instruction{"type"}.getStr == "TimelineAddEntries": + for entry in instruction{"entries"}: + let content = entry{"content"} + if content{"entryType"}.getStr == "TimelineTimelineItem": + with legacy, content{"itemContent", "user_results", "result", "legacy"}: + result.content.add parseUser(legacy) + elif content{"cursorType"}.getStr == "Bottom": + result.bottom = content{"value"}.getStr - for u in js{"users"}: - result.content.add parseProfile(u) proc parsePoll(js: JsonNode): Poll = let vals = js{"binding_values"} @@ -206,7 +225,7 @@ proc parseTweet(js: JsonNode): Tweet = time: js{"created_at"}.getTime, hasThread: js{"self_thread"}.notNull, available: true, - profile: Profile(id: js{"user_id_str"}.getStr), + user: User(id: js{"user_id_str"}.getStr), stats: TweetStats( replies: js{"reply_count"}.getInt, retweets: js{"retweet_count"}.getInt, @@ -244,7 +263,7 @@ proc parseTweet(js: JsonNode): Tweet = of "video": result.video = some(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: - result.attribution = some(parseProfile(user)) + result.attribution = some(parseUser(user)) of "animated_gif": result.gif = some(parseGif(m)) else: discard @@ -298,36 +317,32 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects = users = ? js{"globalObjects", "users"} for k, v in users: - result.users[k] = parseProfile(v, k) + result.users[k] = parseUser(v, k) for k, v in tweets: var tweet = parseTweet(v) - if tweet.profile.id in result.users: - tweet.profile = result.users[tweet.profile.id] + if tweet.user.id in result.users: + tweet.user = result.users[tweet.user.id] result.tweets[k] = tweet proc parseThread(js: JsonNode; global: GlobalObjects): tuple[thread: Chain, self: bool] = result.thread = Chain() - for t in js{"content", "timelineModule", "items"}: - let content = t{"item", "content"} - if "Self" in content{"tweet", "displayType"}.getStr: + + let thread = js{"content", "item", "content", "conversationThread"} + with cursor, thread{"showMoreCursor"}: + result.thread.cursor = cursor{"value"}.getStr + result.thread.hasMore = true + + for t in thread{"conversationComponents"}: + let content = t{"conversationTweetComponent", "tweet"} + + if content{"displayType"}.getStr == "SelfThread": result.self = true - let entry = t{"entryId"}.getStr - if "show_more" in entry: - let - cursor = content{"timelineCursor"} - more = cursor{"displayTreatment", "actionText"}.getStr - result.thread.cursor = cursor{"value"}.getStr - if more.len > 0 and more[0].isDigit(): - result.thread.more = parseInt(more[0 ..< more.find(" ")]) - else: - result.thread.more = -1 - else: - var tweet = finalizeTweet(global, t.getEntryId) - if not tweet.available: - tweet.tombstone = getTombstone(content{"tombstone"}) - result.thread.content.add tweet + var tweet = finalizeTweet(global, content{"id"}.getStr) + if not tweet.available: + tweet.tombstone = getTombstone(content{"tombstone"}) + result.thread.content.add tweet proc parseConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) @@ -373,8 +388,8 @@ proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNod elif "bottom" in r{"entryId"}.getStr: res.bottom = r.getCursor -proc parseUsers*(js: JsonNode; after=""): Result[Profile] = - result = Result[Profile](beginning: after.len == 0) +proc parseUsers*(js: JsonNode; after=""): Result[User] = + result = Result[User](beginning: after.len == 0) let global = parseGlobalObjects(? js) let instructions = ? js{"timeline", "instructions"} @@ -404,7 +419,7 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = for e in instructions[0]{"addEntries", "entries"}: let entry = e{"entryId"}.getStr - if "tweet" in entry or "sq-I-t" in entry or "tombstone" in entry: + if "tweet" in entry or entry.startsWith("sq-I-t") or "tombstone" in entry: let tweet = finalizeTweet(global, e.getEntryId) if not tweet.available: continue result.content.add tweet @@ -412,6 +427,12 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = result.top = e.getCursor elif "cursor-bottom" in entry: result.bottom = e.getCursor + elif entry.startsWith("sq-C"): + with cursor, e{"content", "operation", "cursor"}: + if cursor{"cursorType"}.getStr == "Bottom": + result.bottom = cursor{"value"}.getStr + else: + result.top = cursor{"value"}.getStr proc parsePhotoRail*(js: JsonNode): PhotoRail = for tweet in js: diff --git a/src/parserutils.nim b/src/parserutils.nim index 4b18202..0e7c8b5 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -119,6 +119,16 @@ proc getBanner*(js: JsonNode): string = if color.len > 0: return '#' & color + # use primary color from profile picture color histogram + with p, js{"profile_image_extensions", "mediaColor", "r", "ok", "palette"}: + if p.len > 0: + let pal = p[0]{"rgb"} + result = "#" + result.add toHex(pal{"red"}.getInt, 2) + result.add toHex(pal{"green"}.getInt, 2) + result.add toHex(pal{"blue"}.getInt, 2) + return + proc getTombstone*(js: JsonNode): string = result = js{"tombstoneInfo", "richText", "text"}.getStr result.removeSuffix(" Learn more") @@ -184,13 +194,13 @@ proc deduplicate(s: var seq[ReplaceSlice]) = proc cmp(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b) -proc expandProfileEntities*(profile: var Profile; js: JsonNode) = +proc expandUserEntities*(user: var User; js: JsonNode) = let - orig = profile.bio.toRunes + orig = user.bio.toRunes ent = ? js{"entities"} with urls, ent{"url", "urls"}: - profile.website = urls[0]{"expanded_url"}.getStr + user.website = urls[0]{"expanded_url"}.getStr var replacements = newSeq[ReplaceSlice]() @@ -201,9 +211,9 @@ proc expandProfileEntities*(profile: var Profile; js: JsonNode) = replacements.deduplicate replacements.sort(cmp) - profile.bio = orig.replacedWith(replacements, 0 .. orig.len) - profile.bio = profile.bio.replacef(unRegex, unReplace) - .replacef(htRegex, htReplace) + user.bio = orig.replacedWith(replacements, 0 .. orig.len) + user.bio = user.bio.replacef(unRegex, unReplace) + .replacef(htRegex, htReplace) proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = let diff --git a/src/redis_cache.nim b/src/redis_cache.nim index f272d3e..cb1c5f6 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, times, strutils, tables, hashes +import asyncdispatch, times, strformat, strutils, tables, hashes import redis, redpool, flatty, supersnappy import types, api @@ -51,9 +51,10 @@ proc initRedisPool*(cfg: Config) {.async.} = await migrate("userBuckets", "p:*") await migrate("profileDates", "p:*") await migrate("profileStats", "p:*") + await migrate("userType", "p:*") pool.withAcquire(r): - # optimize memory usage for profile ID buckets + # optimize memory usage for user ID buckets await r.configSet("hash-max-ziplist-entries", "1000") except OSError: @@ -61,9 +62,10 @@ proc initRedisPool*(cfg: Config) {.async.} = stdout.flushFile quit(1) -template pidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000) -template profileKey(name: string): string = "p:" & name +template uidKey(name: string): string = "pid:" & $(hash(name) div 1_000_000) +template userKey(name: string): string = "p:" & name template listKey(l: List): string = "l:" & l.id +template tweetKey(id: int64): string = "t:" & $id proc get(query: string): Future[string] {.async.} = pool.withAcquire(r): @@ -73,25 +75,29 @@ proc setEx(key: string; time: int; data: string) {.async.} = pool.withAcquire(r): dawait r.setEx(key, time, data) +proc cacheUserId(username, id: string) {.async.} = + if username.len == 0 or id.len == 0: return + let name = toLower(username) + pool.withAcquire(r): + dawait r.hSet(name.uidKey, name, id) + proc cache*(data: List) {.async.} = await setEx(data.listKey, listCacheTime, compress(toFlatty(data))) proc cache*(data: PhotoRail; name: string) {.async.} = await setEx("pr:" & toLower(name), baseCacheTime, compress(toFlatty(data))) -proc cache*(data: Profile) {.async.} = +proc cache*(data: User) {.async.} = if data.username.len == 0: return let name = toLower(data.username) + await cacheUserId(name, data.id) pool.withAcquire(r): - dawait r.setEx(name.profileKey, baseCacheTime, compress(toFlatty(data))) - if data.id.len > 0: - dawait r.hSet(name.pidKey, name, data.id) + dawait r.setEx(name.userKey, baseCacheTime, compress(toFlatty(data))) -proc cacheProfileId(username, id: string) {.async.} = - if username.len == 0 or id.len == 0: return - let name = toLower(username) +proc cache*(data: Tweet) {.async.} = + if data.isNil or data.id == 0: return pool.withAcquire(r): - dawait r.hSet(name.pidKey, name, id) + dawait r.setEx(data.id.tweetKey, baseCacheTime, compress(toFlatty(data))) proc cacheRss*(query: string; rss: Rss) {.async.} = let key = "rss:" & query @@ -100,24 +106,34 @@ proc cacheRss*(query: string; rss: Rss) {.async.} = dawait r.hSet(key, "min", rss.cursor) dawait r.expire(key, rssCacheTime) -proc getProfileId*(username: string): Future[string] {.async.} = +template deserialize(data, T) = + try: + result = fromFlatty(uncompress(data), T) + except: + echo "Decompression failed($#): '$#'" % [astToStr(T), data] + +proc getUserId*(username: string): Future[string] {.async.} = let name = toLower(username) pool.withAcquire(r): - result = await r.hGet(name.pidKey, name) + result = await r.hGet(name.uidKey, name) if result == redisNil: - result.setLen(0) + let user = await getUser(username) + if user.suspended: + return "suspended" + else: + await cacheUserId(name, user.id) + return user.id -proc getCachedProfile*(username: string; fetch=true): Future[Profile] {.async.} = +proc getCachedUser*(username: string; fetch=true): Future[User] {.async.} = let prof = await get("p:" & toLower(username)) if prof != redisNil: - result = fromFlatty(uncompress(prof), Profile) + prof.deserialize(User) elif fetch: - result = await getProfile(username) - await cacheProfileId(result.username, result.id) - if result.suspended: - await cache(result) + let userId = await getUserId(username) + result = await getGraphUser(userId) + await cache(result) -proc getCachedProfileUsername*(userId: string): Future[string] {.async.} = +proc getCachedUsername*(userId: string): Future[string] {.async.} = let key = "i:" & userId username = await get(key) @@ -125,15 +141,26 @@ proc getCachedProfileUsername*(userId: string): Future[string] {.async.} = if username != redisNil: result = username else: - let profile = await getProfileById(userId) - result = profile.username + let user = await getUserById(userId) + result = user.username await setEx(key, baseCacheTime, result) +proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = + if id == 0: return + let tweet = await get(id.tweetKey) + if tweet != redisNil: + tweet.deserialize(Tweet) + else: + let conv = await getTweet($id) + if not conv.isNil: + result = conv.tweet + await cache(result) + proc getCachedPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return let rail = await get("pr:" & toLower(name)) if rail != redisNil: - result = fromFlatty(uncompress(rail), PhotoRail) + rail.deserialize(PhotoRail) else: result = await getPhotoRail(name) await cache(result, name) @@ -143,7 +170,7 @@ proc getCachedList*(username=""; slug=""; id=""): Future[List] {.async.} = else: await get("l:" & id) if list != redisNil: - result = fromFlatty(uncompress(list), List) + list.deserialize(List) else: if id.len > 0: result = await getGraphList(id) diff --git a/src/routes/list.nim b/src/routes/list.nim index 0efe515..d466080 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -47,5 +47,5 @@ proc createListRouter*(cfg: Config) = prefs = cookiePrefs() list = await getCachedList(id=(@"id")) title = "@" & list.username & "/" & list.name - members = await getListMembers(list, getCursor()) + members = await getGraphListMembers(list, getCursor()) respList(list, members, title, renderTimelineUsers(members, prefs, request.path)) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 771a3ad..af7312d 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -12,32 +12,32 @@ export times, hashes, supersnappy proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} = var profile: Profile - var timeline: Timeline let name = req.params.getOrDefault("name") after = getCursor(req) names = getNames(name) if names.len == 1: - (profile, timeline) = await fetchTimeline(after, query, skipRail=true) + profile = await fetchProfile(after, query, skipRail=true, skipPinned=true) else: var q = query q.fromUser = names - timeline = await getSearch[Tweet](q, after) - # this is kinda dumb profile = Profile( - username: name, - fullname: names.join(" | "), - userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" + tweets: await getSearch[Tweet](q, after), + # this is kinda dumb + user: User( + username: name, + fullname: names.join(" | "), + userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png" + ) ) - if profile.suspended: - return Rss(feed: profile.username, cursor: "suspended") + if profile.user.suspended: + return Rss(feed: profile.user.username, cursor: "suspended") - if profile.fullname.len > 0: - let rss = compress renderTimelineRss(timeline, profile, cfg, - multi=(names.len > 1)) - return Rss(feed: rss, cursor: timeline.bottom) + if profile.user.fullname.len > 0: + let rss = compress renderTimelineRss(profile, cfg, multi=(names.len > 1)) + return Rss(feed: rss, cursor: profile.tweets.bottom) template respRss*(rss, page) = if rss.cursor.len == 0: diff --git a/src/routes/search.nim b/src/routes/search.nim index b3f8db7..3fc44a9 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -25,7 +25,7 @@ proc createSearchRouter*(cfg: Config) = of users: if "," in @"q": redirect("/" & @"q") - let users = await getSearch[Profile](query, getCursor()) + let users = await getSearch[User](query, getCursor()) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs) of tweets: let diff --git a/src/routes/status.nim b/src/routes/status.nim index 70e31cb..0303152 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, sequtils, uri, options +import asyncdispatch, strutils, sequtils, uri, options, sugar import jester, karax/vdom @@ -7,7 +7,7 @@ import router_utils import ".."/[types, formatters, api] import ../views/[general, status] -export uri, sequtils, options +export uri, sequtils, options, sugar export router_utils export api, formatters export status @@ -16,6 +16,7 @@ proc createStatusRouter*(cfg: Config) = router status: get "/@name/status/@id/?": cond '.' notin @"name" + cond not @"id".any(c => not c.isDigit) let prefs = cookiePrefs() # used for the infinite scroll feature @@ -37,7 +38,7 @@ proc createStatusRouter*(cfg: Config) = let title = pageTitle(conv.tweet) - ogTitle = pageTitle(conv.tweet.profile) + ogTitle = pageTitle(conv.tweet.user) desc = conv.tweet.text var diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 815e08e..052349a 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -19,62 +19,57 @@ proc getQuery*(request: Request; tab, name: string): Query = of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) -proc fetchTimeline*(after: string; query: Query; skipRail=false): - Future[(Profile, Timeline, PhotoRail)] {.async.} = +proc fetchProfile*(after: string; query: Query; skipRail=false; + skipPinned=false): Future[Profile] {.async.} = let name = query.fromUser[0] - var - profile: Profile - profileId = await getProfileId(name) - fetched = false - - if profileId.len == 0: - profile = await getCachedProfile(name) - profileId = profile.id - fetched = true - - if profile.protected or profile.suspended: - return (profile, Timeline(), @[]) - elif profileId.len == 0: - return (Profile(username: name), Timeline(), @[]) + let userId = await getUserId(name) + if userId.len == 0: + return Profile(user: User(username: name)) + elif userId == "suspended": + return Profile(user: User(username: name, suspended: true)) var rail: Future[PhotoRail] - if skipRail or profile.protected or query.kind == media: + if skipRail or result.user.protected or query.kind == media: rail = newFuture[PhotoRail]() rail.complete(@[]) else: rail = getCachedPhotoRail(name) - # var timeline = - # case query.kind - # of posts: await getTimeline(profileId, after) - # of replies: await getTimeline(profileId, after, replies=true) - # of media: await getMediaTimeline(profileId, after) - # else: await getSearch[Tweet](query, after) + # temporary fix to prevent errors from people browsing + # timelines during/immediately after deployment + var after = after + if query.kind in {posts, replies} and after.startsWith("scroll"): + after.setLen 0 - var timeline = + let timeline = case query.kind - of media: await getMediaTimeline(profileId, after) - else: await getSearch[Tweet](query, after) + of posts: getTimeline(userId, after) + of replies: getTimeline(userId, after, replies=true) + of media: getMediaTimeline(userId, after) + else: getSearch[Tweet](query, after) - timeline.query = query + let user = await getCachedUser(name) - var found = false - for tweet in timeline.content.mitems: - if tweet.profile.id == profileId or - tweet.profile.username.cmpIgnoreCase(name) == 0: - profile = tweet.profile - found = true - break + var pinned: Option[Tweet] + if not skipPinned and user.pinnedTweet > 0 and + after.len == 0 and query.kind in {posts, replies}: + let tweet = await getCachedTweet(user.pinnedTweet) + if not tweet.isNil: + tweet.pinned = true + pinned = some tweet - if profile.username.len == 0: - profile = await getCachedProfile(name) - fetched = true + result = Profile( + user: user, + pinned: pinned, + tweets: await timeline, + photoRail: await rail + ) - if fetched and not found: - await cache(profile) + if result.user.protected or result.user.suspended: + return - return (profile, timeline, await rail) + result.tweets.query = query proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = @@ -84,15 +79,18 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; html = renderTweetSearch(timeline, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) - var (p, t, r) = await fetchTimeline(after, query) + var profile = await fetchProfile(after, query) + template u: untyped = profile.user - if p.suspended: return showError(getSuspended(p.username), cfg) - if p.id.len == 0: return + if u.suspended: + return showError(getSuspended(u.username), cfg) - let pHtml = renderProfile(p, t, r, prefs, getPath()) - result = renderMain(pHtml, request, cfg, prefs, pageTitle(p), pageDesc(p), - rss=rss, images = @[p.getUserPic("_400x400")], - banner=p.banner) + if profile.user.id.len == 0: return + + let pHtml = renderProfile(profile, prefs, getPath()) + result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), + rss=rss, images = @[u.getUserPic("_400x400")], + banner=u.banner) template respTimeline*(timeline: typed) = let t = timeline @@ -102,7 +100,7 @@ template respTimeline*(timeline: typed) = template respUserId*() = cond @"user_id".len > 0 - let username = await getCachedProfileUsername(@"user_id") + let username = await getCachedUsername(@"user_id") if username.len > 0: redirect("/" & username) else: @@ -137,10 +135,10 @@ proc createTimelineRouter*(cfg: Config) = timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: - var (_, timeline, _) = await fetchTimeline(after, query, skipRail=true) - if timeline.content.len == 0: resp Http404 - timeline.beginning = true - resp $renderTimelineTweets(timeline, prefs, getPath()) + var profile = await fetchProfile(after, query, skipRail=true) + if profile.tweets.content.len == 0: resp Http404 + profile.tweets.beginning = true + resp $renderTimelineTweets(profile.tweets, prefs, getPath()) let rss = if @"tab".len == 0: diff --git a/src/tokens.nim b/src/tokens.nim index d864a4c..f95a199 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -37,7 +37,7 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.listBySlug, Api.list: 500 + of Api.listBySlug, Api.list, Api.userRestId: 500 of Api.timeline: 187 else: 180 reqs = maxReqs - token.apis[api].remaining diff --git a/src/types.nim b/src/types.nim index ce3446b..d132165 100644 --- a/src/types.nim +++ b/src/types.nim @@ -10,13 +10,13 @@ type Api* {.pure.} = enum userShow - photoRail timeline search tweet list listBySlug listMembers + userRestId RateLimit* = object remaining*: int @@ -44,7 +44,7 @@ type badToken = 239 noCsrf = 353 - Profile* = object + User* = object id*: string username*: string fullname*: string @@ -53,6 +53,7 @@ type bio*: string userPic*: string banner*: string + pinnedTweet*: int64 following*: int followers*: int tweets*: int @@ -162,7 +163,7 @@ type id*: int64 threadId*: int64 replyId*: int64 - profile*: Profile + user*: User text*: string time*: DateTime reply*: seq[string] @@ -173,8 +174,8 @@ type location*: string stats*: TweetStats retweet*: Option[Tweet] - attribution*: Option[Profile] - mediaTags*: seq[Profile] + attribution*: Option[User] + mediaTags*: seq[User] quote*: Option[Tweet] card*: Option[Card] poll*: Option[Poll] @@ -190,7 +191,7 @@ type Chain* = object content*: seq[Tweet] - more*: int64 + hasMore*: bool cursor*: string Conversation* = ref object @@ -201,6 +202,12 @@ type Timeline* = Result[Tweet] + Profile* = object + user*: User + photoRail*: PhotoRail + pinned*: Option[Tweet] + tweets*: Timeline + List* = object id*: string name*: string @@ -212,7 +219,7 @@ type GlobalObjects* = ref object tweets*: Table[string, Tweet] - users*: Table[string, Profile] + users*: Table[string, User] Config* = ref object address*: string diff --git a/src/views/profile.nim b/src/views/profile.nim index e44b8a2..9eda46d 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -12,32 +12,32 @@ proc renderStat(num: int; class: string; text=""): VNode = span(class="profile-stat-num"): text insertSep($num, ',') -proc renderProfileCard*(profile: Profile; prefs: Prefs): VNode = +proc renderUserCard*(user: User; prefs: Prefs): VNode = buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let - url = getPicUrl(profile.getUserPic()) + url = getPicUrl(user.getUserPic()) size = - if prefs.autoplayGifs and profile.userPic.endsWith("gif"): "" + if prefs.autoplayGifs and user.userPic.endsWith("gif"): "" else: "_400x400" a(class="profile-card-avatar", href=url, target="_blank"): - genImg(profile.getUserPic(size)) + genImg(user.getUserPic(size)) tdiv(class="profile-card-tabs-name"): - linkUser(profile, class="profile-card-fullname") - linkUser(profile, class="profile-card-username") + linkUser(user, class="profile-card-fullname") + linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): - if profile.bio.len > 0: + if user.bio.len > 0: tdiv(class="profile-bio"): p(dir="auto"): - verbatim replaceUrls(profile.bio, prefs) + verbatim replaceUrls(user.bio, prefs) - if profile.location.len > 0: + if user.location.len > 0: tdiv(class="profile-location"): span: icon "location" - let (place, url) = getLocation(profile) + let (place, url) = getLocation(user) if url.len > 1: a(href=url): text place elif "://" in place: @@ -45,29 +45,29 @@ proc renderProfileCard*(profile: Profile; prefs: Prefs): VNode = else: span: text place - if profile.website.len > 0: + if user.website.len > 0: tdiv(class="profile-website"): span: - let url = replaceUrls(profile.website, prefs) + let url = replaceUrls(user.website, prefs) icon "link" a(href=url): text shortLink(url) tdiv(class="profile-joindate"): - span(title=getJoinDateFull(profile)): - icon "calendar", getJoinDate(profile) + span(title=getJoinDateFull(user)): + icon "calendar", getJoinDate(user) tdiv(class="profile-card-extra-links"): ul(class="profile-statlist"): - renderStat(profile.tweets, "posts", text="Tweets") - renderStat(profile.following, "following") - renderStat(profile.followers, "followers") - renderStat(profile.likes, "likes") + renderStat(user.tweets, "posts", text="Tweets") + renderStat(user.following, "following") + renderStat(user.followers, "followers") + renderStat(user.likes, "likes") -proc renderPhotoRail(profile: Profile; photoRail: PhotoRail): VNode = - let count = insertSep($profile.media, ',') +proc renderPhotoRail(profile: Profile): VNode = + let count = insertSep($profile.user.media, ',') buildHtml(tdiv(class="photo-rail-card")): tdiv(class="photo-rail-header"): - a(href=(&"/{profile.username}/media")): + a(href=(&"/{profile.user.username}/media")): icon "picture", count & " Photos and videos" input(id="photo-rail-grid-toggle", `type`="checkbox") @@ -76,18 +76,19 @@ proc renderPhotoRail(profile: Profile; photoRail: PhotoRail): VNode = icon "down" tdiv(class="photo-rail-grid"): - for i, photo in photoRail: + for i, photo in profile.photoRail: if i == 16: break - a(href=(&"/{profile.username}/status/{photo.tweetId}#m")): + a(href=(&"/{profile.user.username}/status/{photo.tweetId}#m")): genImg(photo.url & (if "format" in photo.url: "" else: ":thumb")) proc renderBanner(banner: string): VNode = buildHtml(): - if banner.startsWith('#'): + if banner.len == 0: + a() + elif banner.startsWith('#'): a(style={backgroundColor: banner}) else: - a(href=getPicUrl(banner), target="_blank"): - genImg(banner) + a(href=getPicUrl(banner), target="_blank"): genImg(banner) proc renderProtected(username: string): VNode = buildHtml(tdiv(class="timeline-container")): @@ -95,22 +96,21 @@ proc renderProtected(username: string): VNode = h2: text "This account's tweets are protected." p: text &"Only confirmed followers have access to @{username}'s tweets." -proc renderProfile*(profile: Profile; timeline: var Timeline; - photoRail: PhotoRail; prefs: Prefs; path: string): VNode = - timeline.query.fromUser = @[profile.username] +proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = + profile.tweets.query.fromUser = @[profile.user.username] + buildHtml(tdiv(class="profile-tabs")): if not prefs.hideBanner: tdiv(class="profile-banner"): - if profile.banner.len > 0: - renderBanner(profile.banner) + renderBanner(profile.user.banner) let sticky = if prefs.stickyProfile: " sticky" else: "" tdiv(class=(&"profile-tab{sticky}")): - renderProfileCard(profile, prefs) - if photoRail.len > 0: - renderPhotoRail(profile, photoRail) + renderUserCard(profile.user, prefs) + if profile.photoRail.len > 0: + renderPhotoRail(profile) - if profile.protected: - renderProtected(profile.username) + if profile.user.protected: + renderProtected(profile.user.username) else: - renderTweetSearch(timeline, prefs, path) + renderTweetSearch(profile.tweets, prefs, path, profile.pinned) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 6287c57..3e0cd19 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -15,18 +15,18 @@ proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = if text.len > 0: text " " & text -proc linkUser*(profile: Profile, class=""): VNode = +proc linkUser*(user: User, class=""): VNode = let isName = "username" notin class - href = "/" & profile.username - nameText = if isName: profile.fullname - else: "@" & profile.username + href = "/" & user.username + nameText = if isName: user.fullname + else: "@" & user.username buildHtml(a(href=href, class=class, title=nameText)): text nameText - if isName and profile.verified: + if isName and user.verified: icon "ok", class="verified-icon", title="Verified account" - if isName and profile.protected: + if isName and user.protected: text " " icon "lock", title="Protected account" diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 6590430..cf69be1 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -60,7 +60,7 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #let urlPrefix = getUrlPrefix(cfg) #var links: seq[string] #for t in tweets: -# let retweet = if t.retweet.isSome: t.profile.username else: "" +# let retweet = if t.retweet.isSome: t.user.username else: "" # let tweet = if retweet.len > 0: t.retweet.get else: t # let link = getLink(tweet) # if link in links: continue @@ -68,7 +68,7 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} # links.add link ${getTitle(tweet, retweet)} - @${tweet.profile.username} + @${tweet.user.username} ${getRfc822Time(tweet)} ${urlPrefix & link} @@ -77,32 +77,32 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #end for #end proc # -#proc renderTimelineRss*(timeline: Timeline; profile: Profile; cfg: Config; multi=false): string = +#proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string = #let urlPrefix = getUrlPrefix(cfg) #result = "" -#let user = (if multi: "" else: "@") & profile.username -#var title = profile.fullname -#if not multi: title &= " / " & user +#let handle = (if multi: "" else: "@") & profile.user.username +#var title = profile.user.fullname +#if not multi: title &= " / " & handle #end if #title = xmltree.escape(title).sanitizeXml - + ${title} - ${urlPrefix}/${profile.username} - ${getDescription(user, cfg)} + ${urlPrefix}/${profile.user.username} + ${getDescription(handle, cfg)} en-us 40 ${title} - ${urlPrefix}/${profile.username} - ${urlPrefix}${getPicUrl(profile.getUserPic(style="_400x400"))} + ${urlPrefix}/${profile.user.username} + ${urlPrefix}${getPicUrl(profile.user.getUserPic(style="_400x400"))} 128 128 -#if timeline.content.len > 0: -${renderRssTweets(timeline.content, cfg)} +#if profile.tweets.content.len > 0: +${renderRssTweets(profile.tweets.content, cfg)} #end if diff --git a/src/views/search.nim b/src/views/search.nim index 9b70be8..8102412 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, sequtils, unicode, tables +import strutils, strformat, sequtils, unicode, tables, options import karax/[karaxdsl, vdom] import renderutils, timeline @@ -88,7 +88,8 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "Near" genInput("near", "", query.near, placeholder="Location...") -proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string): VNode = +proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string; + pinned=none(Tweet)): VNode = let query = results.query buildHtml(tdiv(class="timeline-container")): if query.fromUser.len > 1: @@ -105,9 +106,9 @@ proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string): VNo if query.fromUser.len == 0: renderSearchTabs(query) - renderTimelineTweets(results, prefs, path) + renderTimelineTweets(results, prefs, path, pinned) -proc renderUserSearch*(results: Result[Profile]; prefs: Prefs): VNode = +proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-container")): tdiv(class="timeline-header"): form(`method`="get", action="/search", class="search-field"): diff --git a/src/views/status.nim b/src/views/status.nim index ab2690a..71c2c67 100644 --- a/src/views/status.nim +++ b/src/views/status.nim @@ -10,24 +10,22 @@ proc renderEarlier(thread: Chain): VNode = text "earlier replies" proc renderMoreReplies(thread: Chain): VNode = - let num = if thread.more != -1: $thread.more & " " else: "" - let reply = if thread.more == 1: "reply" else: "replies" let link = getLink(thread.content[^1]) buildHtml(tdiv(class="timeline-item more-replies")): if thread.content[^1].available: a(class="more-replies-text", href=link): - text $num & "more " & reply + text "more replies" else: a(class="more-replies-text"): - text $num & "more " & reply + text "more replies" proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = buildHtml(tdiv(class="reply thread thread-line")): for i, tweet in thread.content: - let last = (i == thread.content.high and thread.more == 0) + let last = (i == thread.content.high and not thread.hasMore) renderTweet(tweet, prefs, path, index=i, last=last) - if thread.more != 0: + if thread.hasMore: renderMoreReplies(thread) proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = @@ -60,12 +58,12 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode tdiv(class="after-tweet thread-line"): let total = conv.after.content.high - more = conv.after.more + hasMore = conv.after.hasMore for i, tweet in conv.after.content: renderTweet(tweet, prefs, path, index=i, - last=(i == total and more == 0), afterTweet=true) + last=(i == total and not hasMore), afterTweet=true) - if more != 0: + if hasMore: renderMoreReplies(conv.after) if not prefs.hideReplies: diff --git a/src/views/timeline.nim b/src/views/timeline.nim index f4b410a..ccf7c7b 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -57,7 +57,7 @@ proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet elif t.replyId == result[0].id: result.add t -proc renderUser(user: Profile; prefs: Prefs): VNode = +proc renderUser(user: User; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-item")): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"): @@ -73,7 +73,7 @@ proc renderUser(user: Profile; prefs: Prefs): VNode = tdiv(class="tweet-content media-body", dir="auto"): verbatim replaceUrls(user.bio, prefs) -proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs; path=""): VNode = +proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = buildHtml(tdiv(class="timeline")): if not results.beginning: renderNewer(results.query, path) @@ -89,11 +89,16 @@ proc renderTimelineUsers*(results: Result[Profile]; prefs: Prefs; path=""): VNod else: renderNoMore() -proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string): VNode = +proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string; + pinned=none(Tweet)): VNode = buildHtml(tdiv(class="timeline")): if not results.beginning: renderNewer(results.query, parseUri(path).path) + if pinned.isSome: + let tweet = get pinned + renderTweet(tweet, prefs, path, showThread=tweet.hasThread) + if results.content.len == 0: if not results.beginning: renderNoMore() diff --git a/src/views/tweet.nim b/src/views/tweet.nim index e8dfc83..8b712a6 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -13,8 +13,8 @@ proc getSmallPic(url: string): string = result &= ":small" result = getPicUrl(result) -proc renderMiniAvatar(profile: Profile; prefs: Prefs): VNode = - let url = getPicUrl(profile.getUserPic("_mini")) +proc renderMiniAvatar(user: User; prefs: Prefs): VNode = + let url = getPicUrl(user.getUserPic("_mini")) buildHtml(): img(class=(prefs.getAvatarClass & " mini"), src=url) @@ -29,16 +29,16 @@ proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode = span: icon "pin", "Pinned Tweet" tdiv(class="tweet-header"): - a(class="tweet-avatar", href=("/" & tweet.profile.username)): + a(class="tweet-avatar", href=("/" & tweet.user.username)): var size = "_bigger" - if not prefs.autoplayGifs and tweet.profile.userPic.endsWith("gif"): + if not prefs.autoplayGifs and tweet.user.userPic.endsWith("gif"): size = "_400x400" - genImg(tweet.profile.getUserPic(size), class=prefs.getAvatarClass) + genImg(tweet.user.getUserPic(size), class=prefs.getAvatarClass) tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): - linkUser(tweet.profile, class="fullname") - linkUser(tweet.profile, class="username") + linkUser(tweet.user, class="fullname") + linkUser(tweet.user, class="username") span(class="tweet-date"): a(href=getLink(tweet), title=tweet.getTime): @@ -203,14 +203,14 @@ proc renderReply(tweet: Tweet): VNode = if i > 0: text " " a(href=("/" & u)): text "@" & u -proc renderAttribution(profile: Profile; prefs: Prefs): VNode = - buildHtml(a(class="attribution", href=("/" & profile.username))): - renderMiniAvatar(profile, prefs) - strong: text profile.fullname - if profile.verified: +proc renderAttribution(user: User; prefs: Prefs): VNode = + buildHtml(a(class="attribution", href=("/" & user.username))): + renderMiniAvatar(user, prefs) + strong: text user.fullname + if user.verified: icon "ok", class="verified-icon", title="Verified account" -proc renderMediaTags(tags: seq[Profile]): VNode = +proc renderMediaTags(tags: seq[User]): VNode = buildHtml(tdiv(class="media-tag-block")): icon "user" for i, p in tags: @@ -244,9 +244,9 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="tweet-name-row"): tdiv(class="fullname-and-username"): - renderMiniAvatar(quote.profile, prefs) - linkUser(quote.profile, class="fullname") - linkUser(quote.profile, class="username") + renderMiniAvatar(quote.user, prefs) + linkUser(quote.user, class="fullname") + linkUser(quote.user, class="username") span(class="tweet-date"): a(href=getLink(quote), title=quote.getTime): @@ -301,7 +301,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; var tweet = fullTweet if tweet.retweet.isSome: tweet = tweet.retweet.get - retweet = fullTweet.profile.fullname + retweet = fullTweet.user.fullname buildHtml(tdiv(class=("timeline-item " & divClass))): if not mainTweet: @@ -312,7 +312,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; renderHeader(tweet, retweet, prefs) if not afterTweet and index == 0 and tweet.reply.len > 0 and - (tweet.reply.len > 1 or tweet.reply[0] != tweet.profile.username): + (tweet.reply.len > 1 or tweet.reply[0] != tweet.user.username): renderReply(tweet) var tweetClass = "tweet-content media-body" diff --git a/tests/test_profile.py b/tests/test_profile.py index d9aa4fa..e62f7b9 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -18,9 +18,8 @@ protected = [ invalid = [['thisprofiledoesntexist'], ['%']] banner_color = [ - ['TheTwoffice', '29, 161, 242'], - ['profiletest', '80, 176, 58'], - ['nim_lang', '24, 26, 36'] + ['nim_lang', '22, 25, 32'], + ['rustlang', '35, 31, 32'] ] banner_image = [