From 4d6fdda39b232d2fac89959d6a1d559d12f5888e Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Tue, 28 Jun 2022 14:29:05 +0200 Subject: [PATCH 01/26] initial notes support --- src/api.nim | 6 +++ src/consts.nim | 1 + src/formatters.nim | 10 ++--- src/nitter.nim | 4 +- src/parser.nim | 53 ++++++++++++++++++++++++++ src/routes/notes.nim | 17 +++++++++ src/routes/unsupported.nim | 2 +- src/sass/index.scss | 1 + src/sass/note.scss | 44 +++++++++++++++++++++ src/types.nim | 37 ++++++++++++++++++ src/views/notes.nim | 78 ++++++++++++++++++++++++++++++++++++++ src/views/tweet.nim | 4 +- 12 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 src/routes/notes.nim create mode 100644 src/sass/note.scss create mode 100644 src/views/notes.nim diff --git a/src/api.nim b/src/api.nim index dfcf41366..b28423f34 100644 --- a/src/api.nim +++ b/src/api.nim @@ -51,6 +51,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 bb4e1a3c5..14d7cf166 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -25,6 +25,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 363091752..b097f90d5 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -121,14 +121,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 2e868a44b..349e38a7d 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) @@ -100,4 +101,5 @@ routes: extend status, "" extend media, "" extend embed, "" + extend notes, "" extend debug, "" diff --git a/src/parser.nim b/src/parser.nim index fa877f9f3..4838d3532 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -435,3 +435,56 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.replies.content.add thread elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", "itemContent", "value"}.getStr + +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 diff --git a/src/routes/notes.nim b/src/routes/notes.nim new file mode 100644 index 000000000..39670bd26 --- /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 0c085d4d8..3939b5ad3 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 9e2e34717..1d9e4669a 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 000000000..865987526 --- /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 6f742d19e..f60321299 100644 --- a/src/types.nim +++ b/src/types.nim @@ -20,6 +20,7 @@ type userRestId userScreenName status + article RateLimit* = object remaining*: int @@ -120,6 +121,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 000000000..03d626b7e --- /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 ea94e28c3..e9103f52c 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -38,7 +38,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 @@ -253,7 +253,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) From dd8efcbea944646bfecd2b0b12e8936d729ad763 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Tue, 28 Jun 2022 14:53:54 +0200 Subject: [PATCH 02/26] notes: use runeSubStr for ranges --- src/views/notes.nim | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 03d626b7e..d8125c32e 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, tables, strformat +import strutils, tables, strformat, unicode import karax/[karaxdsl, vdom, vstyles] from jester import Request @@ -31,13 +31,13 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): for er in articleParagraph.entityRanges: # flush remaining text if er.offset > last: - result.add text text.substr(last, er.offset - 1) + result.add text text.runeSubStr(last, er.offset - last) 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) + text text.runeSubStr(er.offset, er.length) result.add link of ArticleEntityType.media: for id in entity.mediaIds: @@ -55,7 +55,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): # flush remaining text if last < text.len: - result.add text text.substr(last) + result.add text text.runeSubStr(last) proc renderNote*(article: Article; prefs: Prefs): VNode = let cover = getSmallPic(article.coverImage) From f5ea8ec5df8ebd0f8356f4fd7c17d455a7244416 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 00:35:49 +0200 Subject: [PATCH 03/26] notes: implement lists, headers and twemoji --- src/parser.nim | 7 +++- src/sass/note.scss | 12 +++++- src/types.nim | 23 +++++++++++- src/views/notes.nim | 89 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 107 insertions(+), 24 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 4838d3532..74394fc77 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -452,13 +452,14 @@ proc parseGraphArticle*(js: JsonNode): Article = for p in content{"blocks"}: var paragraph = ArticleParagraph( - text: p{"text"}.getStr + text: p{"text"}.getStr, + baseType: parseEnum[ArticleType](p{"type"}.getStr) ) for sr in p{"inlineStyleRanges"}: paragraph.inlineStyleRanges.add ArticleStyleRange( offset: sr{"offset"}.getInt, length: sr{"length"}.getInt, - style: sr{"style"}.getStr + style: parseEnum[ArticleStyle](sr{"style"}.getStr) ) for er in p{"entityRanges"}: paragraph.entityRanges.add ArticleEntityRange( @@ -481,6 +482,8 @@ proc parseGraphArticle*(js: JsonNode): Article = entity.mediaIds.add jMedia{"mediaId"}.getStr of ArticleEntityType.tweet: entity.tweetId = jEntity{"data", "tweetId"}.getStr + of ArticleEntityType.twemoji: + entity.twemoji = jEntity{"data", "url"}.getStr else: discard result.entities.add entity diff --git a/src/sass/note.scss b/src/sass/note.scss index 865987526..3197bc67d 100644 --- a/src/sass/note.scss +++ b/src/sass/note.scss @@ -2,6 +2,7 @@ max-width: 600px; margin: 0 auto; background-color: var(--bg_panel); + font-family: sans-serif; article { padding: 20px; @@ -18,9 +19,12 @@ margin: 30px 0; } - p { + p, + li { font-size: 18px; - font-family: sans-serif; + } + + p { line-height: 1.5em; margin: 30px 0; word-wrap: break-word; @@ -37,6 +41,10 @@ } } + li { + line-height: 2em; + } + iframe { width: 100%; height: 400px; diff --git a/src/types.nim b/src/types.nim index f60321299..0be8155c6 100644 --- a/src/types.nim +++ b/src/types.nim @@ -132,13 +132,30 @@ type ArticleParagraph* = object text*: string + baseType*: ArticleType inlineStyleRanges*: seq[ArticleStyleRange] entityRanges*: seq[ArticleEntityRange] + ArticleType* {.pure.} = enum + headerOne = "header-one" + headerTwo = "header-two" + headerThree = "header-three" + orderedListItem = "ordered-list-item" + unorderedListItem = "unordered-list-item" + unstyled = "unstyled" + atomic = "atomic" + unknown + ArticleStyleRange* = object offset*: int length*: int - style*: string + style*: ArticleStyle + + ArticleStyle* {.pure.} = enum + bold = "BOLD" + italic = "ITALIC" + strikethrough = "STRIKETHROUGH" + unknown ArticleEntityRange* = object offset*: int @@ -150,11 +167,13 @@ type url*: string mediaIds*: seq[string] tweetId*: string + twemoji*: string - ArticleEntityType* {.pure.} = enum + ArticleEntityType* {.pure.} = enum link = "LINK" media = "MEDIA" tweet = "TWEET" + twemoji = "TWEMOJI" unknown Poll* = object diff --git a/src/views/notes.nim b/src/views/notes.nim index d8125c32e..d4051a09e 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -21,11 +21,31 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode = 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) + case articleParagraph.baseType + of ArticleType.headerOne: + result = h1.newVNode() + of ArticleType.headerTwo: + result = h2.newVNode() + of ArticleType.headerThree: + result = h3.newVNode() + of ArticleType.orderedListItem: + result = li.newVNode() + of ArticleType.unorderedListItem: + result = li.newVNode() + else: + result = p.newVNode() + + # Assume the style applies for the entire paragraph + for styleRange in articleParagraph.inlineStyleRanges: + case styleRange.style + of ArticleStyle.bold: + result.setAttr("style", "font-weight:bold") + of ArticleStyle.italic: + result.setAttr("style", "font-style:italic") + of ArticleStyle.strikethrough: + result.setAttr("style", "text-decoration:line-through") + else: discard var last = 0 for er in articleParagraph.entityRanges: @@ -45,6 +65,10 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): let image = buildHtml(span(class="image")): img(src=url, alt="") result.add image + of ArticleEntityType.twemoji: + let url = entity.twemoji + let emoji = buildHtml(img(src=url, alt="")) + result.add emoji of ArticleEntityType.tweet: let url = fmt"/i/status/{entity.tweetId}/embed" let iframe = buildHtml(iframe(src=url, loading="lazy", frameborder="0", style={maxWidth: "100%"})) @@ -61,18 +85,47 @@ 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 + # build header + let main = buildHtml(article): + h1: text article.title + + tdiv(class="author"): + renderMiniAvatar(author, prefs) + linkUser(author, class="fullname") + linkUser(author, class="username") + text " · " + text article.time.getShortTime + + # add paragraphs + var listType = ArticleType.unknown + var list: VNode = nil - for paragraph in article.paragraphs: - renderNoteParagraph(paragraph, article) + for paragraph in article.paragraphs: + let node = renderNoteParagraph(paragraph, article) + + let currentType = paragraph.baseType + if currentType in [ArticleType.orderedListItem, ArticleType.unorderedListItem]: + if currentType != listType: + # flush last list + if list != nil: + main.add list + list = nil + + case currentType: + of ArticleType.orderedListItem: + list = ol.newVNode() + of ArticleType.unorderedListItem: + list = ul.newVNode() + else: discard + listType = currentType + list.add node + else: + if list != nil: + main.add list + list = nil + main.add node + + buildHtml(tdiv(class="note")): + img(class="cover", src=(cover), alt="") + + main From 075e905d82055dead6744e1898c1a7810918a37e Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 01:11:07 +0200 Subject: [PATCH 04/26] notes: replace hashtags and mentions --- src/formatters.nim | 9 +++++++++ src/views/notes.nim | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index b097f90d5..61821c02d 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -26,6 +26,8 @@ let userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$" extRegex = re"(\.[A-z]+)$" illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]" + hashtagRegex = re"#(\w+)" + mentionRegex = re"@(\w+)" proc getUrlPrefix*(cfg: Config): string = if cfg.useHttps: https & cfg.hostname @@ -72,6 +74,13 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string = if absolute.len > 0 and "href" in result: result = result.replace("href=\"/", &"href=\"{absolute}/") +proc replaceHashtagsAndMentions*(body: string): string = + result = body + result = result.replacef(hashtagRegex, a( + "#$1", href = "/search?q=%23$1")) + result = result.replacef(mentionRegex, a( + "@$1", href = "/$1")) + proc getM3u8Url*(content: string): string = var matches: array[1, string] if re.find(content, m3u8Regex, matches) != -1: diff --git a/src/views/notes.nim b/src/views/notes.nim index d4051a09e..5aa7dbfa3 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -51,13 +51,13 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): for er in articleParagraph.entityRanges: # flush remaining text if er.offset > last: - result.add text text.runeSubStr(last, er.offset - last) + result.add verbatim text.runeSubStr(last, er.offset - last).replaceHashtagsAndMentions let entity = article.entities[er.key] case entity.entityType of ArticleEntityType.link: let link = buildHtml(a(href=entity.url)): - text text.runeSubStr(er.offset, er.length) + verbatim text.runeSubStr(er.offset, er.length).replaceHashtagsAndMentions result.add link of ArticleEntityType.media: for id in entity.mediaIds: @@ -79,7 +79,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): # flush remaining text if last < text.len: - result.add text text.runeSubStr(last) + result.add verbatim text.runeSubStr(last).replaceHashtagsAndMentions proc renderNote*(article: Article; prefs: Prefs): VNode = let cover = getSmallPic(article.coverImage) From feeeb60eb25a59784cbfdb11509722146dcc71fb Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 01:14:15 +0200 Subject: [PATCH 05/26] notes: don't replace text in links --- src/views/notes.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 5aa7dbfa3..244c25164 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -57,7 +57,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): case entity.entityType of ArticleEntityType.link: let link = buildHtml(a(href=entity.url)): - verbatim text.runeSubStr(er.offset, er.length).replaceHashtagsAndMentions + text text.runeSubStr(er.offset, er.length) result.add link of ArticleEntityType.media: for id in entity.mediaIds: From 12788ac65f4ad84f5edae7f59e995d1f05f2a4ac Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 01:29:25 +0200 Subject: [PATCH 06/26] prevent karax from inserting whitespaces to fix wrapping --- src/views/notes.nim | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/views/notes.nim b/src/views/notes.nim index 244c25164..84b0c295f 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -49,6 +49,9 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): var last = 0 for er in articleParagraph.entityRanges: + # prevent karax from inserting whitespaces to fix wrapping + result.add text "" + # flush remaining text if er.offset > last: result.add verbatim text.runeSubStr(last, er.offset - last).replaceHashtagsAndMentions From d8ddb29998013a32b29560e270a98c3baea1aa95 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 01:37:33 +0200 Subject: [PATCH 07/26] notes: proxy small images --- src/views/notes.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 84b0c295f..cf81ca373 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -64,12 +64,12 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): result.add link of ArticleEntityType.media: for id in entity.mediaIds: - let url: string = article.media[id] + let url: string = article.media[id].getSmallPic let image = buildHtml(span(class="image")): img(src=url, alt="") result.add image of ArticleEntityType.twemoji: - let url = entity.twemoji + let url = entity.twemoji.getSmallPic let emoji = buildHtml(img(src=url, alt="")) result.add emoji of ArticleEntityType.tweet: From c7989311868fe972c37439a85263c2f1d0c47e2f Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 01:46:29 +0200 Subject: [PATCH 08/26] add twemoji domain to whitelist --- src/utils.nim | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils.nim b/src/utils.nim index 9002bbf72..e15b5466e 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -15,6 +15,7 @@ const "pic.twitter.com", "twimg.com", "abs.twimg.com", + "abs-0.twimg.com", "pbs.twimg.com", "video.twimg.com" ] From 0e9d7ae2998d734dba3e9e997947e24915355c5f Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 14:12:44 +0200 Subject: [PATCH 09/26] notes: address review --- src/formatters.nim | 4 ++-- src/views/notes.nim | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/formatters.nim b/src/formatters.nim index 61821c02d..0ec71f9f2 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -26,8 +26,8 @@ let userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$" extRegex = re"(\.[A-z]+)$" illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]" - hashtagRegex = re"#(\w+)" - mentionRegex = re"@(\w+)" + hashtagRegex = re"\B#(\w*[A-Za-z]\w*)\b" + mentionRegex = re"\B@(\w{1,15})\b" proc getUrlPrefix*(cfg: Config): string = if cfg.useHttps: https & cfg.hostname diff --git a/src/views/notes.nim b/src/views/notes.nim index cf81ca373..449ff0a43 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -64,7 +64,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): result.add link of ArticleEntityType.media: for id in entity.mediaIds: - let url: string = article.media[id].getSmallPic + let url = article.media[id].getSmallPic let image = buildHtml(span(class="image")): img(src=url, alt="") result.add image From 4de795bd36d59ee44b24310a1af5a252097e7196 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 14:13:02 +0200 Subject: [PATCH 10/26] notes: properly flush list --- src/views/notes.nim | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 449ff0a43..f9b214ed2 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -103,16 +103,19 @@ proc renderNote*(article: Article; prefs: Prefs): VNode = var listType = ArticleType.unknown var list: VNode = nil + proc flushList() = + if list != nil: + main.add list + list = nil + listType = ArticleType.unknown + for paragraph in article.paragraphs: let node = renderNoteParagraph(paragraph, article) let currentType = paragraph.baseType if currentType in [ArticleType.orderedListItem, ArticleType.unorderedListItem]: if currentType != listType: - # flush last list - if list != nil: - main.add list - list = nil + flushList() case currentType: of ArticleType.orderedListItem: @@ -123,10 +126,10 @@ proc renderNote*(article: Article; prefs: Prefs): VNode = listType = currentType list.add node else: - if list != nil: - main.add list - list = nil + flushList() main.add node + + flushList() buildHtml(tdiv(class="note")): img(class="cover", src=(cover), alt="") From 5bcce76bd5bf0bfa879391200990a6222596797c Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 14:37:44 +0200 Subject: [PATCH 11/26] notes: use userRestId api token pool --- src/api.nim | 2 +- src/types.nim | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api.nim b/src/api.nim index b28423f34..6200fb3be 100644 --- a/src/api.nim +++ b/src/api.nim @@ -55,7 +55,7 @@ proc getGraphArticle*(id: string): Future[Article] {.async.} = let variables = %*{"twitterArticleId": id} url = graphArticle ? {"variables": $variables} - result = parseGraphArticle(await fetch(url, Api.article)) + result = parseGraphArticle(await fetch(url, Api.userRestId)) proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = if id.len == 0: return diff --git a/src/types.nim b/src/types.nim index 0be8155c6..b98830f65 100644 --- a/src/types.nim +++ b/src/types.nim @@ -20,7 +20,6 @@ type userRestId userScreenName status - article RateLimit* = object remaining*: int From fd1ad96a86596e61a9c87122a6353f44ca1c8d73 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 15:13:50 +0200 Subject: [PATCH 12/26] notes: match twemoji size with font size --- src/sass/note.scss | 5 +++++ src/views/notes.nim | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sass/note.scss b/src/sass/note.scss index 3197bc67d..4c4419af7 100644 --- a/src/sass/note.scss +++ b/src/sass/note.scss @@ -41,6 +41,11 @@ } } + img.twemoji { + width: 18px; + height: 18px; + } + li { line-height: 2em; } diff --git a/src/views/notes.nim b/src/views/notes.nim index f9b214ed2..c8b0e7039 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -70,7 +70,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): result.add image of ArticleEntityType.twemoji: let url = entity.twemoji.getSmallPic - let emoji = buildHtml(img(src=url, alt="")) + let emoji = buildHtml(img(class="twemoji", src=url, alt="")) result.add emoji of ArticleEntityType.tweet: let url = fmt"/i/status/{entity.tweetId}/embed" From e52f8aa1620b339dbb922fea093973c9b0821b46 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Wed, 29 Jun 2022 15:14:22 +0200 Subject: [PATCH 13/26] notes: ignore empty media --- src/views/notes.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index c8b0e7039..8dceca629 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -64,9 +64,11 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): result.add link of ArticleEntityType.media: for id in entity.mediaIds: - let url = article.media[id].getSmallPic + let url = article.media.getOrDefault(id) + if url == "": + discard let image = buildHtml(span(class="image")): - img(src=url, alt="") + img(src=url.getSmallPic, alt="") result.add image of ArticleEntityType.twemoji: let url = entity.twemoji.getSmallPic From ac9e6f2515045a0d5cfe41c79adbff07c289bf10 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Thu, 30 Jun 2022 18:01:09 +0200 Subject: [PATCH 14/26] notes: render tweets inline this resolves the issue of badly sized iframes and lowers the amount of requests per client --- src/routes/notes.nim | 16 ++++++++++++++-- src/views/notes.nim | 18 +++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/routes/notes.nim b/src/routes/notes.nim index 39670bd26..72ed021d9 100644 --- a/src/routes/notes.nim +++ b/src/routes/notes.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch +import asyncdispatch, tables, asyncfutures, sequtils import jester, karax/vdom import ".."/[types, api] import ../views/[notes, tweet, general] @@ -12,6 +12,18 @@ proc createNotesRouter*(cfg: Config) = get "/i/notes/@id": let prefs = cookiePrefs() + path = getPath() article = await getGraphArticle(@"id") - note = renderNote(article, prefs) + + let tweets = article + .entities + .filterIt(it.entityType == ArticleEntityType.tweet) + .mapIt(getTweet(it.tweetId)) + .all + .await + .filterIt(it != nil) + .mapIt((it.tweet.id, it.tweet)) + .toTable + + let note = renderNote(article, tweets, path, prefs) resp renderMain(note, request, cfg, prefs, titleText=article.title) diff --git a/src/views/notes.nim b/src/views/notes.nim index 8dceca629..e07750bd4 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -1,9 +1,9 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, tables, strformat, unicode -import karax/[karaxdsl, vdom, vstyles] +import strutils, tables, unicode +import karax/[karaxdsl, vdom] from jester import Request -import renderutils +import renderutils, tweet import ".."/[types, utils, formatters] const doctype = "\n" @@ -19,7 +19,7 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode = buildHtml(): img(class=(prefs.getAvatarClass & " mini"), src=url) -proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): VNode = +proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; tweets: Table[int64, Tweet]; path: string; prefs: Prefs): VNode = let text = articleParagraph.text case articleParagraph.baseType @@ -75,9 +75,9 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): let emoji = buildHtml(img(class="twemoji", src=url, alt="")) result.add emoji 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 + let tweet = tweets.getOrDefault(entity.tweetId.parseInt, nil) + if tweet == nil: discard + result.add renderTweet(tweet, prefs, path) else: discard last = er.offset + er.length @@ -86,7 +86,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article): if last < text.len: result.add verbatim text.runeSubStr(last).replaceHashtagsAndMentions -proc renderNote*(article: Article; prefs: Prefs): VNode = +proc renderNote*(article: Article; tweets: Table[int64, Tweet]; path: string; prefs: Prefs): VNode = let cover = getSmallPic(article.coverImage) let author = article.user @@ -112,7 +112,7 @@ proc renderNote*(article: Article; prefs: Prefs): VNode = listType = ArticleType.unknown for paragraph in article.paragraphs: - let node = renderNoteParagraph(paragraph, article) + let node = renderNoteParagraph(paragraph, article, tweets, path, prefs) let currentType = paragraph.baseType if currentType in [ArticleType.orderedListItem, ArticleType.unorderedListItem]: From fd8e267e545cd17ade53cb9467e662c5844b45cc Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Thu, 30 Jun 2022 18:11:16 +0200 Subject: [PATCH 15/26] notes: fix width to 600px --- src/sass/note.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sass/note.scss b/src/sass/note.scss index 4c4419af7..8a5d195fb 100644 --- a/src/sass/note.scss +++ b/src/sass/note.scss @@ -1,5 +1,5 @@ .note { - max-width: 600px; + width: 600px; margin: 0 auto; background-color: var(--bg_panel); font-family: sans-serif; @@ -10,7 +10,7 @@ img.cover { margin: 0; - max-width: 100%; + width: 100%; } h1 { From 8ac79ef00e95b7746def8d8dc4fda6d475326add Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Thu, 30 Jun 2022 19:35:24 +0200 Subject: [PATCH 16/26] notes: support "gifs" --- src/parser.nim | 15 ++++++++++++--- src/sass/note.scss | 6 ++++-- src/types.nim | 11 ++++++++++- src/views/notes.nim | 17 ++++++++++++----- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 74394fc77..f8f538a5b 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -488,6 +488,15 @@ proc parseGraphArticle*(js: JsonNode): Article = result.entities.add entity - for media in article{"media"}: - result.media[media{"media_id"}.getStr] = - media{"media_info", "original_img_url"}.getStr + for m in article{"media"}: + let mediaInfo = m{"media_info"} + var media = ArticleMedia( + mediaType: parseEnum[ArticleMediaType](mediaInfo{"__typename"}.getStr) + ) + case media.mediaType + of ArticleMediaType.image: + media.url = mediaInfo{"original_img_url"}.getStr + of ArticleMediaType.gif: + media.url = mediaInfo{"variants"}[0]{"url"}.getStr + else: discard + result.media[m{"media_id"}.getStr] = media diff --git a/src/sass/note.scss b/src/sass/note.scss index 8a5d195fb..7f7aec7e0 100644 --- a/src/sass/note.scss +++ b/src/sass/note.scss @@ -14,7 +14,7 @@ } h1 { - display:inherit; + display: inherit; font-size: 2.5rem; margin: 30px 0; } @@ -33,7 +33,9 @@ .image { text-align: center; width: 100%; - img { + + img, + video { max-width: 100%; border-radius: 20px; margin: 0 auto; diff --git a/src/types.nim b/src/types.nim index b98830f65..a57d228da 100644 --- a/src/types.nim +++ b/src/types.nim @@ -127,7 +127,7 @@ type time*: DateTime paragraphs*: seq[ArticleParagraph] entities*: seq[ArticleEntity] - media*: Table[string, string] + media*: Table[string, ArticleMedia] ArticleParagraph* = object text*: string @@ -175,6 +175,15 @@ type twemoji = "TWEMOJI" unknown + ArticleMedia* = object + mediaType*: ArticleMediaType + url*: string + + ArticleMediaType* {.pure.} = enum + image = "ApiImage" + gif = "ApiGif" + unknown + Poll* = object options*: seq[string] values*: seq[int] diff --git a/src/views/notes.nim b/src/views/notes.nim index e07750bd4..bf144f51a 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -64,12 +64,19 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t result.add link of ArticleEntityType.media: for id in entity.mediaIds: - let url = article.media.getOrDefault(id) - if url == "": + let media = article.media.getOrDefault(id) + if media.url == "": discard - let image = buildHtml(span(class="image")): - img(src=url.getSmallPic, alt="") - result.add image + case media.mediaType: + of ArticleMediaType.image: + let image = buildHtml(span(class="image")): + img(src=media.url.getSmallPic, alt="") + result.add image + of ArticleMediaType.gif: + let video = buildHtml(span(class="image")): + video(src=media.url.getVidUrl, controls="", autoplay="") + result.add video + else: discard of ArticleEntityType.twemoji: let url = entity.twemoji.getSmallPic let emoji = buildHtml(img(class="twemoji", src=url, alt="")) From 2af64de490a0c785393256ba09e45dce6b164691 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Thu, 30 Jun 2022 19:52:06 +0200 Subject: [PATCH 17/26] notes: treat tweets as mainTweet --- src/views/notes.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index bf144f51a..36905ecb0 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -84,7 +84,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t of ArticleEntityType.tweet: let tweet = tweets.getOrDefault(entity.tweetId.parseInt, nil) if tweet == nil: discard - result.add renderTweet(tweet, prefs, path) + result.add renderTweet(tweet, prefs, path, mainTweet=true) else: discard last = er.offset + er.length From b304d22dd13d5b2796cb933781d3872b03d3b4c1 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Fri, 1 Jul 2022 15:44:29 +0200 Subject: [PATCH 18/26] notes: fetch tweets in a nim idiomatic way --- src/routes/notes.nim | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/routes/notes.nim b/src/routes/notes.nim index 72ed021d9..166a91a8c 100644 --- a/src/routes/notes.nim +++ b/src/routes/notes.nim @@ -15,15 +15,17 @@ proc createNotesRouter*(cfg: Config) = path = getPath() article = await getGraphArticle(@"id") - let tweets = article - .entities - .filterIt(it.entityType == ArticleEntityType.tweet) - .mapIt(getTweet(it.tweetId)) - .all - .await - .filterIt(it != nil) - .mapIt((it.tweet.id, it.tweet)) - .toTable + var tweetFutures: seq[Future[Conversation]] + for e in article.entities: + if e.entityType == ArticleEntityType.tweet: + tweetFutures.add getTweet(e.tweetId) + + let convs = await tweetFutures.all + + var tweets = initTable[int64, Tweet]() + for c in convs: + if c != nil and c.tweet != nil: + tweets[c.tweet.id] = c.tweet let note = renderNote(article, tweets, path, prefs) resp renderMain(note, request, cfg, prefs, titleText=article.title) From 953089a4d997499e0ea47b44f3f06859cc742060 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Fri, 1 Jul 2022 16:14:03 +0200 Subject: [PATCH 19/26] notes: return 404 on errors --- src/parser.nim | 3 +++ src/routes/notes.nim | 15 +++++++++------ src/types.nim | 3 ++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index f8f538a5b..9cf951952 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -437,6 +437,9 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.replies.bottom = e{"content", "itemContent", "value"}.getStr proc parseGraphArticle*(js: JsonNode): Article = + if not js{"errors"}.isNull: + return + let article = js{"data", "twitterArticle"} let meta = article{"metadata"} diff --git a/src/routes/notes.nim b/src/routes/notes.nim index 166a91a8c..4a41de870 100644 --- a/src/routes/notes.nim +++ b/src/routes/notes.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, tables, asyncfutures, sequtils +import asyncdispatch, tables, asyncfutures, options import jester, karax/vdom import ".."/[types, api] import ../views/[notes, tweet, general] @@ -10,10 +10,10 @@ export api, notes, vdom, tweet, general, router_utils proc createNotesRouter*(cfg: Config) = router notes: get "/i/notes/@id": - let - prefs = cookiePrefs() - path = getPath() - article = await getGraphArticle(@"id") + let article = await getGraphArticle(@"id") + + if article == nil: + resp Http404 var tweetFutures: seq[Future[Conversation]] for e in article.entities: @@ -27,5 +27,8 @@ proc createNotesRouter*(cfg: Config) = if c != nil and c.tweet != nil: tweets[c.tweet.id] = c.tweet - let note = renderNote(article, tweets, path, prefs) + let + path = getPath() + prefs = cookiePrefs() + note = renderNote(article, tweets, path, prefs) resp renderMain(note, request, cfg, prefs, titleText=article.title) diff --git a/src/types.nim b/src/types.nim index a57d228da..0658b9876 100644 --- a/src/types.nim +++ b/src/types.nim @@ -38,6 +38,7 @@ type protectedUser = 22 couldntAuth = 32 doesntExist = 34 + invalidPermission = 37 userNotFound = 50 suspended = 63 rateLimited = 88 @@ -120,7 +121,7 @@ type PhotoRail* = seq[GalleryPhoto] - Article* = object + Article* = ref object title*: string coverImage*: string user*: User From 90f8b074d201bc08c9e609f71d15da89fcf197b6 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Fri, 1 Jul 2022 16:27:06 +0200 Subject: [PATCH 20/26] notes: use absolute paths in style sheet to not apply to embeds --- src/sass/note.scss | 77 +++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/src/sass/note.scss b/src/sass/note.scss index 7f7aec7e0..86ca014f1 100644 --- a/src/sass/note.scss +++ b/src/sass/note.scss @@ -4,56 +4,57 @@ background-color: var(--bg_panel); font-family: sans-serif; - article { - padding: 20px; - } - img.cover { margin: 0; width: 100%; } - h1 { - display: inherit; - font-size: 2.5rem; - margin: 30px 0; - } - - p, - li { - font-size: 18px; - } + article { + padding: 20px; - p { - line-height: 1.5em; - margin: 30px 0; - word-wrap: break-word; - white-space: break-spaces; + &>h1 { + display: inherit; + font-size: 2.5rem; + margin: 30px 0; + } - .image { - text-align: center; - width: 100%; + &>p, + li { + font-size: 18px; + } - img, - video { - max-width: 100%; - border-radius: 20px; - margin: 0 auto; + &>p { + line-height: 1.5em; + margin: 30px 0; + word-wrap: break-word; + white-space: break-spaces; + + .image { + text-align: center; + width: 100%; + + img, + video { + max-width: 100%; + border-radius: 20px; + margin: 0 auto; + } } } - } - img.twemoji { - width: 18px; - height: 18px; - } + img.twemoji { + width: 18px; + height: 18px; + } - li { - line-height: 2em; - } + &>ul>li, + &>ol>li { + line-height: 2em; + } - iframe { - width: 100%; - height: 400px; + &>iframe { + width: 100%; + height: 400px; + } } } \ No newline at end of file From eebaa7b748d14917557f34403ac4d15a192c17cd Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Fri, 1 Jul 2022 16:37:34 +0200 Subject: [PATCH 21/26] notes: don't emit paragraphs for atomic blocks --- src/sass/note.scss | 18 +++---- src/views/notes.nim | 113 ++++++++++++++++++++++++-------------------- 2 files changed, 70 insertions(+), 61 deletions(-) diff --git a/src/sass/note.scss b/src/sass/note.scss index 86ca014f1..0bc3c0cea 100644 --- a/src/sass/note.scss +++ b/src/sass/note.scss @@ -28,17 +28,17 @@ margin: 30px 0; word-wrap: break-word; white-space: break-spaces; + } - .image { - text-align: center; - width: 100%; + &>span.image { + text-align: center; + width: 100%; - img, - video { - max-width: 100%; - border-radius: 20px; - margin: 0 auto; - } + img, + video { + max-width: 100%; + border-radius: 20px; + margin: 0 auto; } } diff --git a/src/views/notes.nim b/src/views/notes.nim index 36905ecb0..0d9037407 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -20,48 +20,11 @@ proc renderMiniAvatar(user: User; prefs: Prefs): VNode = img(class=(prefs.getAvatarClass & " mini"), src=url) proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; tweets: Table[int64, Tweet]; path: string; prefs: Prefs): VNode = - let text = articleParagraph.text - - case articleParagraph.baseType - of ArticleType.headerOne: - result = h1.newVNode() - of ArticleType.headerTwo: - result = h2.newVNode() - of ArticleType.headerThree: - result = h3.newVNode() - of ArticleType.orderedListItem: - result = li.newVNode() - of ArticleType.unorderedListItem: - result = li.newVNode() - else: - result = p.newVNode() - - # Assume the style applies for the entire paragraph - for styleRange in articleParagraph.inlineStyleRanges: - case styleRange.style - of ArticleStyle.bold: - result.setAttr("style", "font-weight:bold") - of ArticleStyle.italic: - result.setAttr("style", "font-style:italic") - of ArticleStyle.strikethrough: - result.setAttr("style", "text-decoration:line-through") - else: discard - - var last = 0 - for er in articleParagraph.entityRanges: - # prevent karax from inserting whitespaces to fix wrapping - result.add text "" - - # flush remaining text - if er.offset > last: - result.add verbatim text.runeSubStr(last, er.offset - last).replaceHashtagsAndMentions - + if articleParagraph.baseType == ArticleType.atomic: + let er = articleParagraph.entityRanges[0] let entity = article.entities[er.key] + case entity.entityType - of ArticleEntityType.link: - let link = buildHtml(a(href=entity.url)): - text text.runeSubStr(er.offset, er.length) - result.add link of ArticleEntityType.media: for id in entity.mediaIds: let media = article.media.getOrDefault(id) @@ -71,27 +34,73 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t of ArticleMediaType.image: let image = buildHtml(span(class="image")): img(src=media.url.getSmallPic, alt="") - result.add image + result = image of ArticleMediaType.gif: let video = buildHtml(span(class="image")): video(src=media.url.getVidUrl, controls="", autoplay="") - result.add video + result = video else: discard - of ArticleEntityType.twemoji: - let url = entity.twemoji.getSmallPic - let emoji = buildHtml(img(class="twemoji", src=url, alt="")) - result.add emoji of ArticleEntityType.tweet: let tweet = tweets.getOrDefault(entity.tweetId.parseInt, nil) if tweet == nil: discard - result.add renderTweet(tweet, prefs, path, mainTweet=true) + result = renderTweet(tweet, prefs, path, mainTweet=true) else: discard + else: + let text = articleParagraph.text + + case articleParagraph.baseType + of ArticleType.headerOne: + result = h1.newVNode() + of ArticleType.headerTwo: + result = h2.newVNode() + of ArticleType.headerThree: + result = h3.newVNode() + of ArticleType.orderedListItem: + result = li.newVNode() + of ArticleType.unorderedListItem: + result = li.newVNode() + of ArticleType.atomic: + result = nil + else: + result = p.newVNode() + + # Assume the style applies for the entire paragraph + for styleRange in articleParagraph.inlineStyleRanges: + case styleRange.style + of ArticleStyle.bold: + result.setAttr("style", "font-weight:bold") + of ArticleStyle.italic: + result.setAttr("style", "font-style:italic") + of ArticleStyle.strikethrough: + result.setAttr("style", "text-decoration:line-through") + else: discard + + var last = 0 + for er in articleParagraph.entityRanges: + # prevent karax from inserting whitespaces to fix wrapping + result.add text "" + + # flush remaining text + if er.offset > last: + result.add verbatim text.runeSubStr(last, er.offset - last).replaceHashtagsAndMentions + + let entity = article.entities[er.key] + case entity.entityType + of ArticleEntityType.link: + let link = buildHtml(a(href=entity.url)): + text text.runeSubStr(er.offset, er.length) + result.add link + of ArticleEntityType.twemoji: + let url = entity.twemoji.getSmallPic + let emoji = buildHtml(img(class="twemoji", src=url, alt="")) + result.add emoji + else: discard + + last = er.offset + er.length - last = er.offset + er.length - - # flush remaining text - if last < text.len: - result.add verbatim text.runeSubStr(last).replaceHashtagsAndMentions + # flush remaining text + if last < text.len: + result.add verbatim text.runeSubStr(last).replaceHashtagsAndMentions proc renderNote*(article: Article; tweets: Table[int64, Tweet]; path: string; prefs: Prefs): VNode = let cover = getSmallPic(article.coverImage) From 0ed9414d871405fac8f335077690810a2aebac03 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Sat, 2 Jul 2022 22:40:42 +0200 Subject: [PATCH 22/26] notes: loop "gifs" --- src/views/notes.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 0d9037407..6e9791d6a 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -37,7 +37,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t result = image of ArticleMediaType.gif: let video = buildHtml(span(class="image")): - video(src=media.url.getVidUrl, controls="", autoplay="") + video(src=media.url.getVidUrl, controls="", autoplay="", loop="") result = video else: discard of ArticleEntityType.tweet: From b6aff96e605601dda737ab1a2d531032e03c1aed Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Sat, 2 Jul 2022 22:44:18 +0200 Subject: [PATCH 23/26] notes: bump stylesheet version --- src/views/general.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/general.nim b/src/views/general.nim index 5e96d02eb..87d30f2cd 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,7 +52,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=18") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") if theme.len > 0: From c9552a8b31d3f00346c171978bb054520a45911b Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Fri, 22 Jul 2022 19:09:27 +0200 Subject: [PATCH 24/26] notes: support inlineStyles in the worst way possible --- src/views/notes.nim | 68 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 6e9791d6a..1ac8cc7eb 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -import strutils, tables, unicode +import strutils, tables, unicode, bitops import karax/[karaxdsl, vdom] from jester import Request @@ -64,25 +64,65 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t else: result = p.newVNode() - # Assume the style applies for the entire paragraph - for styleRange in articleParagraph.inlineStyleRanges: - case styleRange.style - of ArticleStyle.bold: - result.setAttr("style", "font-weight:bold") - of ArticleStyle.italic: - result.setAttr("style", "font-style:italic") - of ArticleStyle.strikethrough: - result.setAttr("style", "text-decoration:line-through") - else: discard + proc flushPlainText(target: VNode; start: int; len: int): void = + if articleParagraph.inlineStyleRanges.len == 0: + target.add verbatim text.runeSubStr(start, len).replaceHashtagsAndMentions + else: + + proc flushInternal(start: int, len: int, style: int): void = + let content = text.runeSubStr(start, len).replaceHashtagsAndMentions + echo content, ", ", style + if style == 0: + target.add text content + else: + let container = span.newVNode() + container.add text content + var styleStr = "" + if style.testBit(0): + styleStr.add "font-weight:bold;" + if style.testBit(1): + styleStr.add "font-style:italic;" + if style.testBit(2): + styleStr.add "text-decoration:line-through;" + container.setAttr("style", styleStr) + target.add container + + var + lastStyle = 0 + lastStart = start + + for i in start..(start + len): + var style = 0 + for styleRange in articleParagraph.inlineStyleRanges: + let + styleStart = styleRange.offset + styleEnd = styleStart + styleRange.length + if styleStart <= i and styleEnd >= i: + case styleRange.style: + of ArticleStyle.bold: + style.setBit(0) + of ArticleStyle.italic: + style.setBit(1) + of ArticleStyle.strikethrough: + style.setBit(2) + else: discard + + if style != lastStyle: + flushInternal(lastStart, i - lastStart, lastStyle) + + lastStyle = style + lastStart = i + + if lastStart < len: + flushInternal(lastStart, len - lastStart, lastStyle) var last = 0 for er in articleParagraph.entityRanges: # prevent karax from inserting whitespaces to fix wrapping result.add text "" - # flush remaining text if er.offset > last: - result.add verbatim text.runeSubStr(last, er.offset - last).replaceHashtagsAndMentions + flushPlainText(result, last, er.offset - last) let entity = article.entities[er.key] case entity.entityType @@ -100,7 +140,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t # flush remaining text if last < text.len: - result.add verbatim text.runeSubStr(last).replaceHashtagsAndMentions + flushPlainText(result, last, text.len - last) proc renderNote*(article: Article; tweets: Table[int64, Tweet]; path: string; prefs: Prefs): VNode = let cover = getSmallPic(article.coverImage) From 22113d66d730dd5f9314bcacbf81442f68cc0187 Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Sat, 23 Jul 2022 13:58:19 +0200 Subject: [PATCH 25/26] notes: fix inlineStyle length --- src/views/notes.nim | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 1ac8cc7eb..59824b976 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -71,7 +71,6 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t proc flushInternal(start: int, len: int, style: int): void = let content = text.runeSubStr(start, len).replaceHashtagsAndMentions - echo content, ", ", style if style == 0: target.add text content else: @@ -108,11 +107,13 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t else: discard if style != lastStyle: - flushInternal(lastStart, i - lastStart, lastStyle) - + if i > lastStart: + flushInternal(lastStart, i - lastStart - 1, lastStyle) + lastStart = i - 1 + else: + lastStart = i lastStyle = style - lastStart = i - + if lastStart < len: flushInternal(lastStart, len - lastStart, lastStyle) From 4b2c8bb41d93f9aa0838da34e7e515acb00ae24c Mon Sep 17 00:00:00 2001 From: HookedBehemoth Date: Sat, 23 Jul 2022 15:30:07 +0200 Subject: [PATCH 26/26] notes: properly fix inlineStyle length this time --- src/views/notes.nim | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/views/notes.nim b/src/views/notes.nim index 59824b976..9c883234b 100644 --- a/src/views/notes.nim +++ b/src/views/notes.nim @@ -96,7 +96,7 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t let styleStart = styleRange.offset styleEnd = styleStart + styleRange.length - if styleStart <= i and styleEnd >= i: + if styleStart <= i and styleEnd > i: case styleRange.style: of ArticleStyle.bold: style.setBit(0) @@ -108,11 +108,10 @@ proc renderNoteParagraph(articleParagraph: ArticleParagraph; article: Article; t if style != lastStyle: if i > lastStart: - flushInternal(lastStart, i - lastStart - 1, lastStyle) - lastStart = i - 1 - else: - lastStart = i + flushInternal(lastStart, i - lastStart, lastStyle) + lastStyle = style + lastStart = i if lastStart < len: flushInternal(lastStart, len - lastStart, lastStyle)