Skip to content

Commit f4481c7

Browse files
initial notes support
1 parent 81ec413 commit f4481c7

12 files changed

+248
-9
lines changed

src/api.nim

+6
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
4040
let url = graphListMembers ? {"variables": $variables}
4141
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
4242

43+
proc getGraphArticle*(id: string): Future[Article] {.async.} =
44+
let
45+
variables = %*{"twitterArticleId": id}
46+
url = graphArticle ? {"variables": $variables}
47+
result = parseGraphArticle(await fetch(url, Api.article))
48+
4349
proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} =
4450
if id.len == 0: return
4551
let

src/consts.nim

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const
2323
graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List"
2424
graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug"
2525
graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers"
26+
graphArticle* = graphql / "rJMGbcr9LTsjVycjUmcnEg/TwitterArticleByRestId"
2627

2728
timelineParams* = {
2829
"include_profile_interstitial_type": "0",

src/formatters.nim

+5-5
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,14 @@ proc getTime*(tweet: Tweet): string =
127127
proc getRfc822Time*(tweet: Tweet): string =
128128
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
129129

130-
proc getShortTime*(tweet: Tweet): string =
130+
proc getShortTime*(time: DateTime): string =
131131
let now = now()
132-
let since = now - tweet.time
132+
let since = now - time
133133

134-
if now.year != tweet.time.year:
135-
result = tweet.time.format("d MMM yyyy")
134+
if now.year != time.year:
135+
result = time.format("d MMM yyyy")
136136
elif since.inDays >= 1:
137-
result = tweet.time.format("MMM d")
137+
result = time.format("MMM d")
138138
elif since.inHours >= 1:
139139
result = $since.inHours & "h"
140140
elif since.inMinutes >= 1:

src/nitter.nim

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, tokens
1010
import views/[general, about]
1111
import routes/[
1212
preferences, timeline, status, media, search, rss, list, debug,
13-
unsupported, embed, resolver, router_utils]
13+
unsupported, embed, notes, resolver, router_utils]
1414

1515
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
1616
const issuesUrl = "https://github.com/zedeus/nitter/issues"
@@ -48,6 +48,7 @@ createListRouter(cfg)
4848
createStatusRouter(cfg)
4949
createSearchRouter(cfg)
5050
createMediaRouter(cfg)
51+
createNotesRouter(cfg)
5152
createEmbedRouter(cfg)
5253
createRssRouter(cfg)
5354
createDebugRouter(cfg)
@@ -99,4 +100,5 @@ routes:
99100
extend status, ""
100101
extend media, ""
101102
extend embed, ""
103+
extend notes, ""
102104
extend debug, ""

src/parser.nim

+53
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,56 @@ proc parsePhotoRail*(js: JsonNode): PhotoRail =
415415

416416
if url.len == 0: continue
417417
result.add GalleryPhoto(url: url, tweetId: $t.id)
418+
419+
proc parseGraphArticle*(js: JsonNode): Article =
420+
let article = js{"data", "twitterArticle"}
421+
let meta = article{"metadata"}
422+
423+
result = Article(
424+
title: article{"title"}.getStr,
425+
coverImage: article{"cover_image", "media_info", "original_img_url"}.getStr,
426+
user: meta{"authorResults", "result", "legacy"}.parseUser,
427+
time: meta{"publishedAtMs"}.getStr.parseInt.div(1000).fromUnix.utc,
428+
)
429+
430+
let
431+
content = article{"data", "contentStateJson"}.getStr.parseJson
432+
433+
for p in content{"blocks"}:
434+
var paragraph = ArticleParagraph(
435+
text: p{"text"}.getStr
436+
)
437+
for sr in p{"inlineStyleRanges"}:
438+
paragraph.inlineStyleRanges.add ArticleStyleRange(
439+
offset: sr{"offset"}.getInt,
440+
length: sr{"length"}.getInt,
441+
style: sr{"style"}.getStr
442+
)
443+
for er in p{"entityRanges"}:
444+
paragraph.entityRanges.add ArticleEntityRange(
445+
offset: er{"offset"}.getInt,
446+
length: er{"length"}.getInt,
447+
key: er{"key"}.getInt
448+
)
449+
result.paragraphs.add paragraph
450+
451+
# Note: This is a map but the indices are integers so it's fine.
452+
for _, jEntity in content{"entityMap"}:
453+
var entity = ArticleEntity(
454+
entityType: parseEnum[ArticleEntityType] jEntity{"type"}.getStr,
455+
)
456+
case entity.entityType
457+
of ArticleEntityType.link:
458+
entity.url = jEntity{"data", "url"}.getStr
459+
of ArticleEntityType.media:
460+
for jMedia in jEntity{"data", "mediaItems"}:
461+
entity.mediaIds.add jMedia{"mediaId"}.getStr
462+
of ArticleEntityType.tweet:
463+
entity.tweetId = jEntity{"data", "tweetId"}.getStr
464+
else: discard
465+
466+
result.entities.add entity
467+
468+
for media in article{"media"}:
469+
result.media[media{"media_id"}.getStr] =
470+
media{"media_info", "original_img_url"}.getStr

src/routes/notes.nim

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SPDX-License-Identifier: AGPL-3.0-only
2+
import asyncdispatch
3+
import jester, karax/vdom
4+
import ".."/[types, api]
5+
import ../views/[notes, tweet, general]
6+
import router_utils
7+
8+
export api, notes, vdom, tweet, general, router_utils
9+
10+
proc createNotesRouter*(cfg: Config) =
11+
router notes:
12+
get "/i/notes/@id":
13+
let
14+
prefs = cookiePrefs()
15+
article = await getGraphArticle(@"id")
16+
note = renderNote(article, prefs)
17+
resp renderMain(note, request, cfg, prefs, titleText=article.title)

src/routes/unsupported.nim

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ proc createUnsupportedRouter*(cfg: Config) =
2121
feature()
2222

2323
get "/i/@i?/?@j?":
24-
cond @"i" notin ["status", "lists" , "user"]
24+
cond @"i" notin ["status", "lists" , "user", "notes"]
2525
feature()

src/sass/index.scss

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
@import 'inputs';
88
@import 'timeline';
99
@import 'search';
10+
@import 'note';
1011

1112
body {
1213
// colors

src/sass/note.scss

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
.note {
2+
max-width: 600px;
3+
margin: 0 auto;
4+
background-color: var(--bg_panel);
5+
6+
article {
7+
padding: 20px;
8+
}
9+
10+
img.cover {
11+
margin: 0;
12+
max-width: 100%;
13+
}
14+
15+
h1 {
16+
display:inherit;
17+
font-size: 2.5rem;
18+
margin: 30px 0;
19+
}
20+
21+
p {
22+
font-size: 18px;
23+
font-family: sans-serif;
24+
line-height: 1.5em;
25+
margin: 30px 0;
26+
word-wrap: break-word;
27+
white-space: break-spaces;
28+
29+
.image {
30+
text-align: center;
31+
width: 100%;
32+
img {
33+
max-width: 100%;
34+
border-radius: 20px;
35+
margin: 0 auto;
36+
}
37+
}
38+
}
39+
40+
iframe {
41+
width: 100%;
42+
height: 400px;
43+
}
44+
}

src/types.nim

+37
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type
1818
listMembers
1919
userRestId
2020
status
21+
article
2122

2223
RateLimit* = object
2324
remaining*: int
@@ -115,6 +116,42 @@ type
115116

116117
PhotoRail* = seq[GalleryPhoto]
117118

119+
Article* = object
120+
title*: string
121+
coverImage*: string
122+
user*: User
123+
time*: DateTime
124+
paragraphs*: seq[ArticleParagraph]
125+
entities*: seq[ArticleEntity]
126+
media*: Table[string, string]
127+
128+
ArticleParagraph* = object
129+
text*: string
130+
inlineStyleRanges*: seq[ArticleStyleRange]
131+
entityRanges*: seq[ArticleEntityRange]
132+
133+
ArticleStyleRange* = object
134+
offset*: int
135+
length*: int
136+
style*: string
137+
138+
ArticleEntityRange* = object
139+
offset*: int
140+
length*: int
141+
key*: int
142+
143+
ArticleEntity* = object
144+
entityType*: ArticleEntityType
145+
url*: string
146+
mediaIds*: seq[string]
147+
tweetId*: string
148+
149+
ArticleEntityType* {.pure.} = enum
150+
link = "LINK"
151+
media = "MEDIA"
152+
tweet = "TWEET"
153+
unknown
154+
118155
Poll* = object
119156
options*: seq[string]
120157
values*: seq[int]

src/views/notes.nim

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# SPDX-License-Identifier: AGPL-3.0-only
2+
import strutils, tables, strformat
3+
import karax/[karaxdsl, vdom, vstyles]
4+
from jester import Request
5+
6+
import renderutils
7+
import ".."/[types, utils, formatters]
8+
9+
const doctype = "<!DOCTYPE html>\n"
10+
11+
proc getSmallPic(url: string): string =
12+
result = url
13+
if "?" notin url and not url.endsWith("placeholder.png"):
14+
result &= "?name=small"
15+
result = getPicUrl(result)
16+
17+
proc renderMiniAvatar(user: User; prefs: Prefs): VNode =
18+
let url = getPicUrl(user.getUserPic("_mini"))
19+
buildHtml():
20+
img(class=(prefs.getAvatarClass & " mini"), src=url)
21+
22+
proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): VNode =
23+
let text = articleParagraph.text
24+
result = p.newVNode()
25+
26+
if articleParagraph.inlineStyleRanges.len > 0:
27+
# Assume the style applies for the entire paragraph
28+
result.setAttr("style", "font-style:" & articleParagraph.inlineStyleRanges[0].style.toLowerAscii)
29+
30+
var last = 0
31+
for er in articleParagraph.entityRanges:
32+
# flush remaining text
33+
if er.offset > last:
34+
result.add text text.substr(last, er.offset - 1)
35+
36+
let entity = article.entities[er.key]
37+
case entity.entityType
38+
of ArticleEntityType.link:
39+
let link = buildHtml(a(href=entity.url)):
40+
text text.substr(er.offset, er.offset + er.length - 1)
41+
result.add link
42+
of ArticleEntityType.media:
43+
for id in entity.mediaIds:
44+
let url: string = article.media[id]
45+
let image = buildHtml(span(class="image")):
46+
img(src=url, alt="")
47+
result.add image
48+
of ArticleEntityType.tweet:
49+
let url = fmt"/i/status/{entity.tweetId}/embed"
50+
let iframe = buildHtml(iframe(src=url, loading="lazy", frameborder="0", style={maxWidth: "100%"}))
51+
result.add iframe
52+
else: discard
53+
54+
last = er.offset + er.length
55+
56+
# flush remaining text
57+
if last < text.len:
58+
result.add text text.substr(last)
59+
60+
proc renderNote*(article: Article; prefs: Prefs): VNode =
61+
let cover = getSmallPic(article.coverImage)
62+
let author = article.user
63+
64+
buildHtml(tdiv(class="note")):
65+
img(class="cover", src=(cover), alt="")
66+
67+
article:
68+
h1: text article.title
69+
70+
tdiv(class="author"):
71+
renderMiniAvatar(author, prefs)
72+
linkUser(author, class="fullname")
73+
linkUser(author, class="username")
74+
text " · "
75+
text article.time.getShortTime
76+
77+
for paragraph in article.paragraphs:
78+
renderNoteParagraph(paragraph, article)

src/views/tweet.nim

+2-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ proc renderHeader(tweet: Tweet; retweet: string; prefs: Prefs): VNode =
4545

4646
span(class="tweet-date"):
4747
a(href=getLink(tweet), title=tweet.getTime):
48-
text tweet.getShortTime
48+
text tweet.time.getShortTime
4949

5050
proc renderAlbum(tweet: Tweet): VNode =
5151
let
@@ -261,7 +261,7 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode =
261261

262262
span(class="tweet-date"):
263263
a(href=getLink(quote), title=quote.getTime):
264-
text quote.getShortTime
264+
text quote.time.getShortTime
265265

266266
if quote.reply.len > 0:
267267
renderReply(quote)

0 commit comments

Comments
 (0)