From d95311bf8ffd62cc6383b7df01c902951be5f33c Mon Sep 17 00:00:00 2001 From: jackyzy823 Date: Fri, 1 Oct 2021 13:48:50 +0200 Subject: [PATCH] Implement Recommendations --- src/api.nim | 6 +++ src/consts.nim | 1 + src/parser.nim | 4 ++ src/redis_cache.nim | 12 ++++++ src/routes/rss.nim | 2 +- src/routes/timeline.nim | 19 ++++++--- src/sass/profile/_base.scss | 1 + src/sass/profile/recommendations.scss | 59 +++++++++++++++++++++++++++ src/types.nim | 2 + src/views/profile.nim | 21 +++++++++- src/views/timeline.nim | 2 +- 11 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 src/sass/profile/recommendations.scss diff --git a/src/api.nim b/src/api.nim index 779b3e40d..ada9c4a65 100644 --- a/src/api.nim +++ b/src/api.nim @@ -98,6 +98,12 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) +proc getRecommendations*(id: string): Future[Recommendations] {.async.} = + let + ps = genParams({"user_id": id}) + url = recommendations ? ps + result = parseRecommnedations(await fetch(url, oldApi=true)) + proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/consts.nim b/src/consts.nim index d1822ce0f..91bbd4e8b 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -11,6 +11,7 @@ const userShow* = api / "1.1/users/show.json" photoRail* = api / "1.1/statuses/media_timeline.json" search* = api / "2/search/adaptive.json" + recommendations* = api / "1.1/users/recommendations.json" timelineApi = api / "2/timeline" tweet* = timelineApi / "conversation" diff --git a/src/parser.nim b/src/parser.nim index c6adae782..3b34702b6 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -449,6 +449,10 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = elif "cursor-bottom" in entry: result.bottom = e.getCursor +proc parseRecommnedations*(js: JsonNode): Recommendations = + for u in js: + result.add parseProfile(u{"user"}) + proc parsePhotoRail*(js: JsonNode): PhotoRail = for tweet in js: let diff --git a/src/redis_cache.nim b/src/redis_cache.nim index b434def46..de63fd88d 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -74,6 +74,9 @@ proc cache*(data: List) {.async.} = proc cache*(data: PhotoRail; name: string) {.async.} = await setex("pr:" & name, baseCacheTime, compress(toFlatty(data))) +proc cache*(data: Recommendations; userId: string) {.async.} = + await setex("rc:" & userId, listCacheTime, compress(toFlatty(data))) + proc cache*(data: Profile) {.async.} = if data.username.len == 0 or data.id.len == 0: return let name = toLower(data.username) @@ -99,6 +102,15 @@ proc cacheRss*(query: string; rss: Rss) {.async.} = discard await r.expire(key, rssCacheTime) discard await r.flushPipeline() +proc getCachedRecommendations*(userId: string): Future[Recommendations] {.async.} = + if userId.len == 0: return + let recommendations = await get("rc:" & userId) + if recommendations != redisNil: + result = fromFlatty(uncompress(recommendations), Recommendations) + else: + result = await getRecommendations(userId) + await cache(result, userId) + proc getProfileId*(username: string): Future[string] {.async.} = let name = toLower(username) pool.withAcquire(r): diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 5deb1cf78..ea8368ac6 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -20,7 +20,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. if names.len == 1: (profile, timeline) = - await fetchSingleTimeline(after, query, skipRail=true) + await fetchSingleTimeline(after, query, skipRail=true, skipRecommendations=true) else: var q = query q.fromUser = names diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index e904f2d8c..b687725a4 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -19,8 +19,8 @@ proc getQuery*(request: Request; tab, name: string): Query = of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) -proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): - Future[(Profile, Timeline, PhotoRail)] {.async.} = +proc fetchSingleTimeline*(after: string; query: Query; skipRail=false, skipRecommendations=false): + Future[(Profile, Timeline, PhotoRail, Recommendations)] {.async.} = let name = query.fromUser[0] var @@ -52,6 +52,13 @@ proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): else: rail = getCachedPhotoRail(name) + var recommendations: Future[Recommendations] + if skipRecommendations: + recommendations = newFuture[Recommendations]() + recommendations.complete(@[]) + else: + recommendations = getCachedRecommendations(profileId) + var timeline = case query.kind of posts: await getTimeline(profileId, after) @@ -76,7 +83,7 @@ proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): if fetched and not found: await cache(profile) - return (profile, timeline, await rail) + return (profile, timeline, await rail, await recommendations) proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = @@ -86,12 +93,12 @@ 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 fetchSingleTimeline(after, query) + var (p, t, r, rc) = await fetchSingleTimeline(after, query) if p.suspended: return showError(getSuspended(p.username), cfg) if p.id.len == 0: return - let pHtml = renderProfile(p, t, r, prefs, getPath()) + let pHtml = renderProfile(p, t, r, rc, prefs, getPath()) result = renderMain(pHtml, request, cfg, prefs, pageTitle(p), pageDesc(p), rss=rss, images = @[p.getUserpic("_400x400")], banner=p.banner) @@ -139,7 +146,7 @@ proc createTimelineRouter*(cfg: Config) = timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: - var (_, timeline, _) = await fetchSingleTimeline(after, query, skipRail=true) + var (_, timeline, _, _) = await fetchSingleTimeline(after, query, skipRail=true, skipRecommendations=true) if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTimelineTweets(timeline, prefs, getPath()) diff --git a/src/sass/profile/_base.scss b/src/sass/profile/_base.scss index 23ac4f2ce..b1f0c0508 100644 --- a/src/sass/profile/_base.scss +++ b/src/sass/profile/_base.scss @@ -3,6 +3,7 @@ @import 'card'; @import 'photo-rail'; +@import 'recommendations'; .profile-tabs { @include panel(auto, 900px); diff --git a/src/sass/profile/recommendations.scss b/src/sass/profile/recommendations.scss new file mode 100644 index 000000000..e21781bf9 --- /dev/null +++ b/src/sass/profile/recommendations.scss @@ -0,0 +1,59 @@ +@import '_variables'; + +.recommendations { + &-card { + float: left; + background: var(--bg_panel); + border-radius: 0 0 4px 4px; + width: 100%; + margin: 5px 0; + } + + &-header { + padding: 5px 12px 0; + } + + &-header-mobile { + display: none; + box-sizing: border-box; + padding: 5px 12px 0; + width: 100%; + float: unset; + color: var(--accent); + justify-content: space-between; + } +} + +@include create-toggle(recommendations-list, 640px); +#recommendations-list-toggle:checked ~ .recommendations-list { + padding-bottom: 12px; +} + +@media(max-width: 600px) { + .recommendations-header { + display: none; + } + + .recommendations-header-mobile { + display: flex; + } + + .recommendations-list { + max-height: 0; + padding-bottom: 0; + overflow: scroll; + transition: max-height 0.4s; + } +} + +@media(max-width: 600px) { + #recommendations-list-toggle:checked ~ .recommendations-list { + max-height: 160px; + } +} + +@media(max-width: 450px) { + #recommendations-list-toggle:checked ~ .recommendations-list { + max-height: 160px; + } +} diff --git a/src/types.nim b/src/types.nim index c577244b6..ad94ca769 100644 --- a/src/types.nim +++ b/src/types.nim @@ -99,6 +99,8 @@ type PhotoRail* = seq[GalleryPhoto] + Recommendations* = seq[Profile] + Poll* = object options*: seq[string] values*: seq[int] diff --git a/src/views/profile.nim b/src/views/profile.nim index 6918550b3..8a26101e8 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -2,7 +2,7 @@ import strutils, strformat import karax/[karaxdsl, vdom, vstyles] -import renderutils, search +import renderutils, search, timeline import ".."/[types, utils, formatters] proc renderStat(num, class: string; text=""): VNode = @@ -81,6 +81,20 @@ proc renderPhotoRail(profile: Profile; photoRail: PhotoRail): VNode = style={backgroundColor: col}): genImg(photo.url & (if "format" in photo.url: "" else: ":thumb")) +proc renderRecommendations(recommendations: Recommendations; prefs: Prefs): VNode = + buildHtml(tdiv(class="recommendations-card")): + tdiv(class="recommendations-header"): + span: text "You might like" + + input(id="recommendations-list-toggle", `type`="checkbox") + label(`for`="recommendations-list-toggle", class="recommendations-header-mobile"): + span: text "You might like" + icon "down" + + tdiv(class="recommendations-list"): + for i, recommendation in recommendations: + renderUser(recommendation, prefs) + proc renderBanner(profile: Profile): VNode = buildHtml(): if "#" in profile.banner: @@ -96,7 +110,7 @@ proc renderProtected(username: string): VNode = 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 = + photoRail: PhotoRail; recommendations: Recommendations; prefs: Prefs; path: string): VNode = timeline.query.fromUser = @[profile.username] buildHtml(tdiv(class="profile-tabs")): if not prefs.hideBanner: @@ -108,6 +122,9 @@ proc renderProfile*(profile: Profile; timeline: var Timeline; renderProfileCard(profile, prefs) if photoRail.len > 0: renderPhotoRail(profile, photoRail) + if recommendations.len > 0: + renderRecommendations(recommendations, prefs) + if profile.protected: renderProtected(profile.username) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 4a2f29c43..f7a47d4f7 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: Profile; prefs: Prefs): VNode = buildHtml(tdiv(class="timeline-item")): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"):