Browse Source

Rearchitect profile, support pins, Profile -> User

pull/527/head
Zed 4 months ago
parent
commit
51ae076ea0
  1. 45
      src/api.nim
  2. 7
      src/consts.nim
  3. 28
      src/experimental/parser/user.nim
  4. 2
      src/experimental/types/user.nim
  5. 30
      src/formatters.nim
  6. 95
      src/parser.nim
  7. 22
      src/parserutils.nim
  8. 81
      src/redis_cache.nim
  9. 2
      src/routes/list.nim
  10. 26
      src/routes/rss.nim
  11. 2
      src/routes/search.nim
  12. 7
      src/routes/status.nim
  13. 102
      src/routes/timeline.nim
  14. 2
      src/tokens.nim
  15. 21
      src/types.nim
  16. 74
      src/views/profile.nim
  17. 12
      src/views/renderutils.nim
  18. 26
      src/views/rss.nimf
  19. 9
      src/views/search.nim
  20. 16
      src/views/status.nim
  21. 11
      src/views/timeline.nim
  22. 36
      src/views/tweet.nim
  23. 5
      tests/test_profile.py

45
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

7
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",

28
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<a href=\"/search?q=%23$3\">$2$3</a>"
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)

2
src/experimental/types/user.nim

@ -1,7 +1,7 @@
import common
type
User* = object
RawUser* = object
idStr*: string
name*: string
screenName*: string

30
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: ""

95
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:

22
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

81
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)
proc getCachedProfile*(username: string; fetch=true): Future[Profile] {.async.} =
let user = await getUser(username)
if user.suspended:
return "suspended"
else:
await cacheUserId(name, user.id)
return user.id
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)

2
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))

26
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:

2
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

7
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

102
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 u.suspended:
return showError(getSuspended(u.username), cfg)
if p.suspended: return showError(getSuspended(p.username), cfg)
if p.id.len == 0: return
if profile.user.id.len == 0: return
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)
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:

2
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

21
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

74
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)

12
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"

26
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
<item>
<title>${getTitle(tweet, retweet)}</title>
<dc:creator>@${tweet.profile.username}</dc:creator>
<dc:creator>@${tweet.user.username}</dc:creator>
<description><![CDATA[${renderRssTweet(tweet, cfg).strip(chars={'\n'})}]]></description>
<pubDate>${getRfc822Time(tweet)}</pubDate>
<guid>${urlPrefix & link}</guid>
@ -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
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<atom:link href="${urlPrefix}/${profile.username}/rss" rel="self" type="application/rss+xml" />
<atom:link href="${urlPrefix}/${profile.user.username}/rss" rel="self" type="application/rss+xml" />
<title>${title}</title>
<link>${urlPrefix}/${profile.username}</link>
<description>${getDescription(user, cfg)}</description>
<link>${urlPrefix}/${profile.user.username}</link>
<description>${getDescription(handle, cfg)}</description>
<language>en-us</language>
<ttl>40</ttl>
<image>
<title>${title}</title>
<link>${urlPrefix}/${profile.username}</link>
<url>${urlPrefix}${getPicUrl(profile.getUserPic(style="_400x400"))}</url>
<link>${urlPrefix}/${profile.user.username}</link>
<url>${urlPrefix}${getPicUrl(profile.user.getUserPic(style="_400x400"))}</url>
<width>128</width>
<height>128</height>
</image>
#if timeline.content.len > 0:
${renderRssTweets(timeline.content, cfg)}
#if profile.tweets.content.len > 0:
${renderRssTweets(profile.tweets.content, cfg)}
#end if
</channel>
</rss>

9
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"):

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