diff --git a/src/api.nim b/src/api.nim index 392795c..5bb8854 100644 --- a/src/api.nim +++ b/src/api.nim @@ -40,6 +40,12 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) +proc getGraphArticle*(id: string): Future[Article] {.async.} = + let + variables = %*{"twitterArticleId": id} + url = graphArticle ? {"variables": $variables} + result = parseGraphArticle(await fetch(url, Api.article)) + proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index 3687a54..6dc6123 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -23,6 +23,7 @@ const graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List" graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug" graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers" + graphArticle* = graphql / "rJMGbcr9LTsjVycjUmcnEg/TwitterArticleByRestId" timelineParams* = { "include_profile_interstitial_type": "0", diff --git a/src/formatters.nim b/src/formatters.nim index 28bfa1c..e4331ff 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -127,14 +127,14 @@ proc getTime*(tweet: Tweet): string = proc getRfc822Time*(tweet: Tweet): string = tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") -proc getShortTime*(tweet: Tweet): string = +proc getShortTime*(time: DateTime): string = let now = now() - let since = now - tweet.time + let since = now - time - if now.year != tweet.time.year: - result = tweet.time.format("d MMM yyyy") + if now.year != time.year: + result = time.format("d MMM yyyy") elif since.inDays >= 1: - result = tweet.time.format("MMM d") + result = time.format("MMM d") elif since.inHours >= 1: result = $since.inHours & "h" elif since.inMinutes >= 1: diff --git a/src/nitter.nim b/src/nitter.nim index d743599..a9684a4 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, tokens import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, - unsupported, embed, resolver, router_utils] + unsupported, embed, notes, resolver, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -48,6 +48,7 @@ createListRouter(cfg) createStatusRouter(cfg) createSearchRouter(cfg) createMediaRouter(cfg) +createNotesRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) createDebugRouter(cfg) @@ -99,4 +100,5 @@ routes: extend status, "" extend media, "" extend embed, "" + extend notes, "" extend debug, "" diff --git a/src/parser.nim b/src/parser.nim index b731bdb..6b20989 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -415,3 +415,56 @@ proc parsePhotoRail*(js: JsonNode): PhotoRail = if url.len == 0: continue result.add GalleryPhoto(url: url, tweetId: $t.id) + +proc parseGraphArticle*(js: JsonNode): Article = + let article = js{"data", "twitterArticle"} + let meta = article{"metadata"} + + result = Article( + title: article{"title"}.getStr, + coverImage: article{"cover_image", "media_info", "original_img_url"}.getStr, + user: meta{"authorResults", "result", "legacy"}.parseUser, + time: meta{"publishedAtMs"}.getStr.parseInt.div(1000).fromUnix.utc, + ) + + let + content = article{"data", "contentStateJson"}.getStr.parseJson + + for p in content{"blocks"}: + var paragraph = ArticleParagraph( + text: p{"text"}.getStr + ) + for sr in p{"inlineStyleRanges"}: + paragraph.inlineStyleRanges.add ArticleStyleRange( + offset: sr{"offset"}.getInt, + length: sr{"length"}.getInt, + style: sr{"style"}.getStr + ) + for er in p{"entityRanges"}: + paragraph.entityRanges.add ArticleEntityRange( + offset: er{"offset"}.getInt, + length: er{"length"}.getInt, + key: er{"key"}.getInt + ) + result.paragraphs.add paragraph + + # Note: This is a map but the indices are integers so it's fine. + for _, jEntity in content{"entityMap"}: + var entity = ArticleEntity( + entityType: parseEnum[ArticleEntityType] jEntity{"type"}.getStr, + ) + case entity.entityType + of ArticleEntityType.link: + entity.url = jEntity{"data", "url"}.getStr + of ArticleEntityType.media: + for jMedia in jEntity{"data", "mediaItems"}: + entity.mediaIds.add jMedia{"mediaId"}.getStr + of ArticleEntityType.tweet: + entity.tweetId = jEntity{"data", "tweetId"}.getStr + else: discard + + result.entities.add entity + + for media in article{"media"}: + result.media[media{"media_id"}.getStr] = + media{"media_info", "original_img_url"}.getStr \ No newline at end of file diff --git a/src/routes/notes.nim b/src/routes/notes.nim new file mode 100644 index 0000000..39670bd --- /dev/null +++ b/src/routes/notes.nim @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import asyncdispatch +import jester, karax/vdom +import ".."/[types, api] +import ../views/[notes, tweet, general] +import router_utils + +export api, notes, vdom, tweet, general, router_utils + +proc createNotesRouter*(cfg: Config) = + router notes: + get "/i/notes/@id": + let + prefs = cookiePrefs() + article = await getGraphArticle(@"id") + note = renderNote(article, prefs) + resp renderMain(note, request, cfg, prefs, titleText=article.title) diff --git a/src/routes/unsupported.nim b/src/routes/unsupported.nim index 0c085d4..3939b5a 100644 --- a/src/routes/unsupported.nim +++ b/src/routes/unsupported.nim @@ -21,5 +21,5 @@ proc createUnsupportedRouter*(cfg: Config) = feature() get "/i/@i?/?@j?": - cond @"i" notin ["status", "lists" , "user"] + cond @"i" notin ["status", "lists" , "user", "notes"] feature() diff --git a/src/sass/index.scss b/src/sass/index.scss index 9e2e347..1d9e466 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -7,6 +7,7 @@ @import 'inputs'; @import 'timeline'; @import 'search'; +@import 'note'; body { // colors diff --git a/src/sass/note.scss b/src/sass/note.scss new file mode 100644 index 0000000..8659875 --- /dev/null +++ b/src/sass/note.scss @@ -0,0 +1,44 @@ +.note { + max-width: 600px; + margin: 0 auto; + background-color: var(--bg_panel); + + article { + padding: 20px; + } + + img.cover { + margin: 0; + max-width: 100%; + } + + h1 { + display:inherit; + font-size: 2.5rem; + margin: 30px 0; + } + + p { + font-size: 18px; + font-family: sans-serif; + line-height: 1.5em; + margin: 30px 0; + word-wrap: break-word; + white-space: break-spaces; + + .image { + text-align: center; + width: 100%; + img { + max-width: 100%; + border-radius: 20px; + margin: 0 auto; + } + } + } + + iframe { + width: 100%; + height: 400px; + } +} \ No newline at end of file diff --git a/src/types.nim b/src/types.nim index 061ec8a..a975ea5 100644 --- a/src/types.nim +++ b/src/types.nim @@ -18,6 +18,7 @@ type listMembers userRestId status + article RateLimit* = object remaining*: int @@ -115,6 +116,42 @@ type PhotoRail* = seq[GalleryPhoto] + Article* = object + title*: string + coverImage*: string + user*: User + time*: DateTime + paragraphs*: seq[ArticleParagraph] + entities*: seq[ArticleEntity] + media*: Table[string, string] + + ArticleParagraph* = object + text*: string + inlineStyleRanges*: seq[ArticleStyleRange] + entityRanges*: seq[ArticleEntityRange] + + ArticleStyleRange* = object + offset*: int + length*: int + style*: string + + ArticleEntityRange* = object + offset*: int + length*: int + key*: int + + ArticleEntity* = object + entityType*: ArticleEntityType + url*: string + mediaIds*: seq[string] + tweetId*: string + + ArticleEntityType* {.pure.} = enum + link = "LINK" + media = "MEDIA" + tweet = "TWEET" + unknown + Poll* = object options*: seq[string] values*: seq[int] diff --git a/src/views/notes.nim b/src/views/notes.nim new file mode 100644 index 0000000..03d626b --- /dev/null +++ b/src/views/notes.nim @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: AGPL-3.0-only +import strutils, tables, strformat +import karax/[karaxdsl, vdom, vstyles] +from jester import Request + +import renderutils +import ".."/[types, utils, formatters] + +const doctype = "\n" + +proc getSmallPic(url: string): string = + result = url + if "?" notin url and not url.endsWith("placeholder.png"): + result &= "?name=small" + result = getPicUrl(result) + +proc renderMiniAvatar(user: User; prefs: Prefs): VNode = + let url = getPicUrl(user.getUserPic("_mini")) + buildHtml(): + img(class=(prefs.getAvatarClass & " mini"), src=url) + +proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): VNode = + let text = articleParagraph.text + result = p.newVNode() + + if articleParagraph.inlineStyleRanges.len > 0: + # Assume the style applies for the entire paragraph + result.setAttr("style", "font-style:" & articleParagraph.inlineStyleRanges[0].style.toLowerAscii) + + var last = 0 + for er in articleParagraph.entityRanges: + # flush remaining text + if er.offset > last: + result.add text text.substr(last, er.offset - 1) + + let entity = article.entities[er.key] + case entity.entityType + of ArticleEntityType.link: + let link = buildHtml(a(href=entity.url)): + text text.substr(er.offset, er.offset + er.length - 1) + result.add link + of ArticleEntityType.media: + for id in entity.mediaIds: + let url: string = article.media[id] + let image = buildHtml(span(class="image")): + img(src=url, alt="") + result.add image + of ArticleEntityType.tweet: + let url = fmt"/i/status/{entity.tweetId}/embed" + let iframe = buildHtml(iframe(src=url, loading="lazy", frameborder="0", style={maxWidth: "100%"})) + result.add iframe + else: discard + + last = er.offset + er.length + + # flush remaining text + if last < text.len: + result.add text text.substr(last) + +proc renderNote*(article: Article; prefs: Prefs): VNode = + let cover = getSmallPic(article.coverImage) + let author = article.user + + buildHtml(tdiv(class="note")): + img(class="cover", src=(cover), alt="") + + article: + h1: text article.title + + tdiv(class="author"): + renderMiniAvatar(author, prefs) + linkUser(author, class="fullname") + linkUser(author, class="username") + text " ยท " + text article.time.getShortTime + + for paragraph in article.paragraphs: + renderNoteParagraph(paragraph, article) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index daad61a..c41fa1b 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -45,7 +45,7 @@ proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode = span(class="tweet-date"): a(href=getLink(tweet), title=tweet.getTime): - text tweet.getShortTime + text tweet.time.getShortTime proc renderAlbum(tweet: Tweet): VNode = let @@ -261,7 +261,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = span(class="tweet-date"): a(href=getLink(quote), title=quote.getTime): - text quote.getShortTime + text quote.time.getShortTime if quote.reply.len > 0: renderReply(quote)