mirror of https://github.com/zedeus/nitter
initial notes support
This commit is contained in:
parent
81ec41328d
commit
f4481c7e9a
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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, ""
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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()
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
@import 'inputs';
|
||||
@import 'timeline';
|
||||
@import 'search';
|
||||
@import 'note';
|
||||
|
||||
body {
|
||||
// colors
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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 = "<!DOCTYPE html>\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)
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue