diff --git a/public/css/fontello.css b/public/css/fontello.css index 52362d8..53a66a1 100644 --- a/public/css/fontello.css +++ b/public/css/fontello.css @@ -1,12 +1,12 @@ @font-face { font-family: "fontello"; - src: url("/fonts/fontello.eot?77185648"); + src: url("/fonts/fontello.eot?42791196"); src: - url("/fonts/fontello.eot?77185648#iefix") format("embedded-opentype"), - url("/fonts/fontello.woff2?77185648") format("woff2"), - url("/fonts/fontello.woff?77185648") format("woff"), - url("/fonts/fontello.ttf?77185648") format("truetype"), - url("/fonts/fontello.svg?77185648#fontello") format("svg"); + url("/fonts/fontello.eot?42791196#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?42791196") format("woff2"), + url("/fonts/fontello.woff?42791196") format("woff"), + url("/fonts/fontello.ttf?42791196") format("truetype"), + url("/fonts/fontello.svg?42791196#fontello") format("svg"); font-weight: normal; font-style: normal; } @@ -56,6 +56,11 @@ } /* '' */ +.icon-group:before { + content: "\e804"; +} + +/* '' */ .icon-play:before { content: "\e805"; } diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 8671134..2e05e0b 100644 Binary files a/public/fonts/fontello.eot and b/public/fonts/fontello.eot differ diff --git a/public/fonts/fontello.svg b/public/fonts/fontello.svg index 31bd38c..ccc0436 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -1,7 +1,7 @@ -Copyright (C) 2025 by original authors @ fontello.com +Copyright (C) 2026 by original authors @ fontello.com @@ -14,6 +14,8 @@ + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index 0c04c6c..b4dcf10 100644 Binary files a/public/fonts/fontello.ttf and b/public/fonts/fontello.ttf differ diff --git a/public/fonts/fontello.woff b/public/fonts/fontello.woff index e4582ad..af57828 100644 Binary files a/public/fonts/fontello.woff and b/public/fonts/fontello.woff differ diff --git a/public/fonts/fontello.woff2 b/public/fonts/fontello.woff2 index d2c246d..7efdfbd 100644 Binary files a/public/fonts/fontello.woff2 and b/public/fonts/fontello.woff2 differ diff --git a/src/consts.nim b/src/consts.nim index 38fd870..17827f2 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -113,7 +113,7 @@ const $2 "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withBirdwatchNotes": false, + "withBirdwatchNotes": true, "withVoice": false, "withV2Timeline": true }""".replace(" ", "").replace("\n", "") diff --git a/src/parser.nim b/src/parser.nim index 6b8da60..da0ffb4 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -6,6 +6,12 @@ import experimental/parser/unifiedcard proc parseGraphTweet(js: JsonNode): Tweet +proc parseCommunityNote(js: JsonNode): string = + let subtitle = js{"subtitle"} + result = subtitle{"text"}.getStr + with entities, subtitle{"entities"}: + result = expandBirdwatchEntities(result, entities) + proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( @@ -439,6 +445,9 @@ proc parseGraphTweet(js: JsonNode): Tweet = for id in ids: result.history.add parseBiggestInt(id.getStr) + with birdwatch, js{"birdwatch_pivot"}: + result.note = parseCommunityNote(birdwatch) + proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = for t in ? js{"content", "items"}: let entryId = t.getEntryId diff --git a/src/parserutils.nim b/src/parserutils.nim index 08b3cf8..98dd2ee 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -330,6 +330,26 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) +proc expandBirdwatchEntities*(text: string; entities: JsonNode): string = + let runes = text.toRunes + var replacements: seq[ReplaceSlice] + + for entity in entities: + let + fromIdx = entity{"from_index"}.getInt + toIdx = entity{"to_index"}.getInt + url = entity{"ref", "url"}.getStr + if url.len > 0: + replacements.add ReplaceSlice( + kind: rkUrl, + slice: fromIdx ..< toIdx, + url: url, + display: $runes[fromIdx ..< min(toIdx, runes.len)] + ) + + replacements.sort(cmp) + result = runes.replacedWith(replacements, 0 ..< runes.len) + proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = if t.photos.len > 0: t.photos[0].url diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 149eadf..047caef 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -78,6 +78,9 @@ genPrefs: hideReplies(checkbox, false): "Hide tweet replies" + hideCommunityNotes(checkbox, false): + "Hide community notes" + squareAvatars(checkbox, false): "Square profile pictures" diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 28590e7..b9d64bc 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -254,3 +254,38 @@ pointer-events: all; } } + +.community-note { + background-color: var(--bg_elements); + margin-top: 10px; + border: solid 1px var(--dark_grey); + border-radius: 10px; + overflow: hidden; + pointer-events: all; + + &:hover { + background-color: var(--bg_panel); + border-color: var(--grey); + } +} + +.community-note-header { + background-color: var(--bg_hover); + font-weight: 700; + padding: 8px 10px; + padding-top: 6px; + display: flex; + align-items: center; + gap: 2px; + + .icon-container { + flex-shrink: 0; + color: var(--accent); + } +} + +.community-note-text { + white-space: pre-line; + padding: 10px 10px; + padding-top: 6px; +} diff --git a/src/sass/tweet/card.scss b/src/sass/tweet/card.scss index 5575191..7441d11 100644 --- a/src/sass/tweet/card.scss +++ b/src/sass/tweet/card.scss @@ -1,119 +1,119 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .card { - margin: 5px 0; - pointer-events: all; - max-height: unset; + margin: 5px 0; + pointer-events: all; + max-height: unset; } .card-container { - border-radius: 10px; - border-width: 1px; - border-style: solid; - border-color: var(--dark_grey); - background-color: var(--bg_elements); - overflow: hidden; - color: inherit; - display: flex; - flex-direction: row; - text-decoration: none !important; + border: solid 1px var(--dark_grey); + border-radius: 10px; + background-color: var(--bg_elements); + overflow: hidden; + color: inherit; + display: flex; + flex-direction: row; + text-decoration: none !important; - &:hover { - border-color: var(--grey); - } + &:hover { + border-color: var(--grey); + } - .attachments { - margin: 0; - border-radius: 0; - } + .attachments { + margin: 0; + border-radius: 0; + } } .card-content { - padding: 0.5em; + padding: 0.5em; } .card-title { - @include ellipsis; - white-space: unset; - font-weight: bold; - font-size: 1.1em; + @include ellipsis; + white-space: unset; + font-weight: bold; + font-size: 1.1em; } .card-description { - margin: 0.3em 0; - white-space: pre-wrap; + margin: 0.3em 0; + white-space: pre-wrap; } .card-destination { - @include ellipsis; - color: var(--grey); - display: block; + @include ellipsis; + color: var(--grey); + display: block; } .card-content-container { - color: unset; - overflow: auto; - &:hover { - text-decoration: none; - } + color: unset; + overflow: auto; + + &:hover { + text-decoration: none; + } } .card-image-container { - width: 98px; - flex-shrink: 0; - position: relative; - overflow: hidden; - &:before { - content: ""; - display: block; - padding-top: 100%; - } + width: 98px; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &:before { + content: ""; + display: block; + padding-top: 100%; + } } .card-image { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background-color: var(--bg_overlays); + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: var(--bg_overlays); - img { - width: 100%; - height: 100%; - max-height: 400px; - display: block; - object-fit: cover; - } + img { + width: 100%; + height: 100%; + max-height: 400px; + display: block; + object-fit: cover; + } } .card-overlay { - @include play-button; - opacity: 0.8; - display: flex; - justify-content: center; - align-items: center; + @include play-button; + opacity: 0.8; + display: flex; + justify-content: center; + align-items: center; } .large { - .card-container { - display: block; - } + .card-container { + display: block; + } - .card-image-container { - width: unset; + .card-image-container { + width: unset; - &:before { - display: none; - } + &:before { + display: none; } + } - .card-image { - position: unset; - border-style: solid; - border-color: var(--dark_grey); - border-width: 0; - border-bottom-width: 1px; - } + .card-image { + position: unset; + border-style: solid; + border-color: var(--dark_grey); + border-width: 0; + border-bottom-width: 1px; + } } diff --git a/src/sass/tweet/quote.scss b/src/sass/tweet/quote.scss index 64d7e13..2f3122a 100644 --- a/src/sass/tweet/quote.scss +++ b/src/sass/tweet/quote.scss @@ -19,31 +19,49 @@ } .tweet-name-row { - padding: 6px 8px; - margin-top: 1px; + padding: 8px 10px 6px 10px; } .quote-text { overflow: hidden; white-space: pre-wrap; word-wrap: break-word; - padding: 0px 8px 8px 8px; + padding: 10px; + padding-top: 0; } .show-thread { - padding: 0px 8px 6px 8px; + padding: 0px 10px 6px 10px; margin-top: -6px; } .quote-latest { - padding: 0px 8px 6px 8px; + padding: 0px 10px 6px 10px; color: var(--grey); } .replying-to { - padding: 0px 8px; + padding: 0px 10px; + padding-bottom: 4px; margin: unset; } + + .community-note { + background-color: var(--bg_panel); + border: unset; + border-top: solid 1px var(--dark_grey); + border-radius: unset; + margin-top: 0; + + &:hover { + border-top-color: var(--grey); + } + + .community-note-header { + background-color: var(--bg_panel); + padding-bottom: 0; + } + } } .unavailable-quote { diff --git a/src/types.nim b/src/types.nim index 996f572..56bc2b2 100644 --- a/src/types.nim +++ b/src/types.nim @@ -223,6 +223,7 @@ type video*: Option[Video] photos*: seq[Photo] history*: seq[int64] + note*: string Tweets* = seq[Tweet] diff --git a/src/views/general.nim b/src/views/general.nim index d002777..ac087c1 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,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=27") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=28") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/tweet.nim b/src/views/tweet.nim index f79c571..b0578c1 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -226,6 +226,14 @@ proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = elif quote.gif.isSome: renderGif(quote.gif.get(), prefs) +proc renderCommunityNote(note: string; prefs: Prefs): VNode = + buildHtml(tdiv(class="community-note")): + tdiv(class="community-note-header"): + icon "group" + span: text "Community note" + tdiv(class="community-note-text", dir="auto"): + verbatim replaceUrls(note, prefs) + proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): @@ -261,6 +269,9 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if quote.photos.len > 0 or quote.video.isSome or quote.gif.isSome: renderQuoteMedia(quote, prefs, path) + if quote.note.len > 0 and not prefs.hideCommunityNotes: + renderCommunityNote(quote.note, prefs) + if quote.hasThread: a(class="show-thread", href=getLink(quote)): text "Show this thread" @@ -346,6 +357,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) + if tweet.note.len > 0 and not prefs.hideCommunityNotes: + renderCommunityNote(tweet.note, prefs) + let hasEdits = tweet.history.len > 1 isLatest = hasEdits and tweet.id == max(tweet.history)