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 = [