diff --git a/public/css/fontello.css b/public/css/fontello.css index 53a66a1..eb41de7 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?42791196"); + src: url("/fonts/fontello.eot?49059696"); src: - 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"); + url("/fonts/fontello.eot?49059696#iefix") format("embedded-opentype"), + url("/fonts/fontello.woff2?49059696") format("woff2"), + url("/fonts/fontello.woff?49059696") format("woff"), + url("/fonts/fontello.ttf?49059696") format("truetype"), + url("/fonts/fontello.svg?49059696#fontello") format("svg"); font-weight: normal; font-style: normal; } @@ -126,6 +126,11 @@ } /* '' */ +.icon-attention:before { + content: "\e812"; +} + +/* '' */ .icon-circle:before { content: "\f111"; } diff --git a/public/fonts/fontello.eot b/public/fonts/fontello.eot index 2e05e0b..ed1dba8 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 ccc0436..19db3bd 100644 --- a/public/fonts/fontello.svg +++ b/public/fonts/fontello.svg @@ -42,6 +42,8 @@ + + diff --git a/public/fonts/fontello.ttf b/public/fonts/fontello.ttf index b4dcf10..be34c4e 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 af57828..699b2ba 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 7efdfbd..cfe8dfb 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 cea9cc8..c81443c 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -16,7 +16,7 @@ const graphUserTweetsAndReplies* = "kkaJ0Mf34PZVarrxzLihjg/UserTweetsAndReplies" graphUserMedia* = "36oKqyQ7E_9CmtONGjJRsA/UserMedia" graphUserMediaV2* = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2" - graphTweet* = "Y4Erk_-0hObvLpz0Iw3bzA/ConversationTimeline" + graphTweet* = "b4pV7sWOe97RncwHcGESUA/ConversationTimeline" graphTweetDetail* = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail" graphTweetResult* = "nzme9KiYhfIOrrLrPP_XeQ/TweetResultByIdQuery" graphTweetEditHistory* = "upS9teTSG45aljmP9oTuXA/TweetEditHistory" diff --git a/src/parser.nim b/src/parser.nim index a566fb1..d22e6d1 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -61,7 +61,8 @@ proc parseGraphUser(js: JsonNode): User = result.fullname = user{"core", "name"}.getStr result.userPic = user{"avatar", "image_url"}.getImageStr.replace("_normal", "") - if user{"is_blue_verified"}.getBool(false): + if user{"is_blue_verified"}.getBool( + user{"verification", "is_blue_verified"}.getBool(false)): result.verifiedType = blue with verifiedType, user{"verification", "verified_type"}: @@ -206,6 +207,12 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = )) else: discard + if "expanded_url" in mediaEntity: + let expandedUrl = js.getExpandedUrl + if result.text.endsWith(expandedUrl): + result.text.removeSuffix(expandedUrl) + result.text = result.text.strip() + if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: result.media = parsedMedia @@ -409,7 +416,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = else: discard - if not js.hasKey("legacy"): + if "legacy" notin js and "rest_id" notin js: return Tweet() var jsCard = select(js{"card"}, js{"tweet_card"}, js{"legacy", "tweet_card"}) @@ -432,8 +439,41 @@ proc parseGraphTweet(js: JsonNode): Tweet = with restId, js{"reply_to_results", "rest_id"}: replyId = restId.getId - result = parseTweet(js{"legacy"}, jsCard, replyId) - result.id = js{"rest_id"}.getId + if "details" in js: + result = Tweet( + id: js{"rest_id"}.getId, + available: true, + text: js{"details", "full_text"}.getStr, + time: js{"details", "created_at_ms"}.getTimeFromMs, + replyId: js{"reply_to_results", "rest_id"}.getId, + isAd: js{"content_disclosure", "advertising_disclosure", "is_paid_promotion"}.getBool, + isAI: js{"content_disclosure", "ai_generated_disclosure", "has_ai_generated_media"}.getBool, + stats: TweetStats( + replies: js{"counts", "reply_count"}.getInt, + retweets: js{"counts", "retweet_count"}.getInt, + likes: js{"counts", "favorite_count"}.getInt, + ) + ) + + if jsCard.kind != JNull: + let name = jsCard{"name"}.getStr + if "poll" in name: + if "image" in name: + result.media.addMedia(Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + )) + + result.poll = some parsePoll(jsCard) + elif name == "amplify": + result.media.addMedia(parsePromoVideo(jsCard{"binding_values"})) + else: + result.card = some parseCard(jsCard, js{"url_entities"}) + + result.expandTweetEntitiesV2(js) + else: + result = parseTweet(js{"legacy"}, jsCard, replyId) + result.id = js{"rest_id"}.getId + result.user = parseGraphUser(js{"core"}) if result.reply.len == 0: diff --git a/src/parserutils.nim b/src/parserutils.nim index ea37468..518724e 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -320,6 +320,58 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) = tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) +proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; + hasRedundantLink=false) = + let hasCard = tweet.card.isSome + + var replacements = newSeq[ReplaceSlice]() + + with urls, js{"url_entities"}: + for u in urls: + let urlStr = u["url"].getStr + if urlStr.len == 0 or urlStr notin text: + continue + + replacements.extractUrls(u, textSlice.b, hideTwitter = hasRedundantLink) + + if hasCard and u{"url"}.getStr == get(tweet.card).url: + get(tweet.card).url = u.getExpandedUrl + + with hashtags, js{"details", "hashtag_entities"}: + for hashtag in hashtags: + replacements.extractHashtags(hashtag) + + with cashtags, js{"details", "cashtag_entities"}: + for cashtag in cashtags: + replacements.extractHashtags(cashtag) + + with mentions, js{"mention_entities"}: + for mention in mentions: + let + name = mention{"screen_name"}.getStr + slice = mention.extractSlice + idx = tweet.reply.find(name) + + if slice.a >= textSlice.a: + replacements.add ReplaceSlice(kind: rkMention, slice: slice, + url: "/" & name, display: mention["name"].getStr) + elif idx == -1 and tweet.replyId != 0: + tweet.reply.add name + + replacements.deduplicate + replacements.sort(cmp) + + tweet.text = text.toRunes.replacedWith(replacements, textSlice).strip(leading=false) + +proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) = + let + textRange = js{"details", "display_text_range"} + textSlice = textRange{0}.getInt .. textRange{1}.getInt + hasQuote = "quoted_tweet_results" in js + hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails + + tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard) + proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = let entities = ? js{"entity_set"} diff --git a/src/sass/tweet/_base.scss b/src/sass/tweet/_base.scss index 8019522..5bf5a2c 100644 --- a/src/sass/tweet/_base.scss +++ b/src/sass/tweet/_base.scss @@ -80,8 +80,8 @@ } .tweet-published { - margin-top: 10px; - margin-bottom: 3px; + margin-top: 6px; + margin-bottom: 0px; color: var(--grey); pointer-events: all; } @@ -292,3 +292,16 @@ padding: 10px 10px; padding-top: 6px; } + +.disclosures { + display: flex; + flex-direction: column; + color: var(--grey); + font-size: 14px; + margin-top: 4px; + margin-bottom: -2px; + + .icon-attention { + margin-right: -3px; + } +} diff --git a/src/types.nim b/src/types.nim index 4f52886..c2aca50 100644 --- a/src/types.nim +++ b/src/types.nim @@ -240,6 +240,8 @@ type media*: MediaEntities history*: seq[int64] note*: string + isAd*: bool + isAI*: bool Tweets* = seq[Tweet] diff --git a/src/views/tweet.nim b/src/views/tweet.nim index b1f805c..aae56e9 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -257,10 +257,6 @@ proc renderLatestPost(username: string; id: int64): VNode = a(href=getLink(id, username)): text "See the latest post" -proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = - buildHtml(tdiv(class="quote-media-container")): - renderMedia(quote.media, prefs, path) - proc renderCommunityNote(note: string; prefs: Prefs): VNode = buildHtml(tdiv(class="community-note")): tdiv(class="community-note-header"): @@ -269,6 +265,10 @@ proc renderCommunityNote(note: string; prefs: Prefs): VNode = tdiv(class="community-note-text", dir="auto"): verbatim replaceUrls(note, prefs) +proc renderQuoteMedia(quote: Tweet; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="quote-media-container")): + renderMedia(quote.media, prefs, path) + proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = if not quote.available: return buildHtml(tdiv(class="quote unavailable")): @@ -315,6 +315,15 @@ proc renderQuote(quote: Tweet; prefs: Prefs; path: string): VNode = tdiv(class="quote-latest"): text "There's a new version of this post" +proc renderDisclosures*(tweet: Tweet): VNode = + buildHtml(tdiv(class="disclosures")): + if tweet.isAI: + span(data-disclosure="ai"): + icon "attention", "Made with AI" + if tweet.isAd: + span(data-disclosure="ad"): + icon "attention", "Paid partnership (ad)" + proc renderLocation*(tweet: Tweet): string = let (place, url) = tweet.getLocation() if place.len == 0: return @@ -391,6 +400,9 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.note.len > 0 and not prefs.hideCommunityNotes: renderCommunityNote(tweet.note, prefs) + if tweet.isAI or tweet.isAd: + renderDisclosures(tweet) + let hasEdits = tweet.history.len > 1 isLatest = hasEdits and tweet.id == max(tweet.history)