diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 2c8afee..6909edc 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/md/about.md b/public/md/about.md index 8a471af..1bcb949 100644 --- a/public/md/about.md +++ b/public/md/about.md @@ -5,7 +5,7 @@ privacy and performance. The source is available on GitHub at **This instance is running a fork, whose source can be found at** - + * No JavaScript or ads * All requests go through the backend, client never talks to Twitter diff --git a/src/api.nim b/src/api.nim index 0a79071..bc5db05 100644 --- a/src/api.nim +++ b/src/api.nim @@ -100,7 +100,7 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" variables = tweetVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures, "fieldToggles": tweetFieldToggles} + params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphTweet ? params, Api.tweetDetail) result = parseGraphConversation(js, id) diff --git a/src/apiutils.nim b/src/apiutils.nim index b92b9d6..c5fe79b 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -116,8 +116,7 @@ template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = #release(token, used=true) if resp.status == $Http400: - let errText = "body: '" & result & "' url: " & $url - raise newException(InternalError, errText) + raise newException(InternalError, $url) except InternalError as e: raise e except BadClientError as e: diff --git a/src/consts.nim b/src/consts.nim index 68ade47..8eb2057 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -20,7 +20,7 @@ const graphUserTweets* = graphql / "3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2" graphUserTweetsAndReplies* = graphql / "8IS8MaO-2EN6GZZZb8jF0g/UserWithProfileTweetsAndRepliesQueryV2" graphUserMedia* = graphql / "dexO_2tohK86JDudXXG3Yw/UserMedia" - graphTweet* = graphql / "y90SwUGBZ3yz0yNUmCHgTw/TweetDetail" + graphTweet* = graphql / "q94uRCEn65LZThakYcPT6g/TweetDetail" graphTweetResult* = graphql / "sITyJdhRPpvpEjg4waUmTA/TweetResultByIdQuery" graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" @@ -95,36 +95,30 @@ const "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false, "verified_phone_label_enabled": false, "vibe_api_enabled": false, - "view_counts_everywhere_api_enabled": false, - "premium_content_api_read_enabled": false, - "responsive_web_grok_analyze_button_fetch_trends_enabled": false, - "responsive_web_grok_analysis_button_from_backend": false, - "responsive_web_grok_analyze_post_followups_enabled": false, - "responsive_web_jetfuel_frame": false, - "profile_label_improvements_pcf_label_in_post_enabled": true, - "responsive_web_grok_image_annotation_enabled": false, - "responsive_web_grok_share_attachment_enabled": false, - "rweb_video_screen_enabled": false + "view_counts_everywhere_api_enabled": false }""".replace(" ", "").replace("\n", "") tweetVariables* = """{ "focalTweetId": "$1", $2 - "with_rux_injections": false, - "rankingMode": "Relevance", + "includeHasBirdwatchNotes": false, "includePromotedContent": false, - "withCommunity": true, - "withQuickPromoteEligibilityTweetFields": false, - "withBirdwatchNotes": true, - "withVoice": true + "withBirdwatchNotes": false, + "withVoice": false, + "withV2Timeline": true }""".replace(" ", "").replace("\n", "") - tweetFieldToggles* = """{ - "withArticleRichContentState": false, - "withArticlePlainText": true, - "withGrokAnalyze": false, - "withDisallowedReplyControls": false -}""".replace(" ", "").replace("\n", "") +# oldUserTweetsVariables* = """{ +# "userId": "$1", $2 +# "count": 20, +# "includePromotedContent": false, +# "withDownvotePerspective": false, +# "withReactionsMetadata": false, +# "withReactionsPerspective": false, +# "withVoice": false, +# "withV2Timeline": true +# } +# """.replace(" ", "").replace("\n", "") userTweetsVariables* = """{ "rest_id": "$1", @@ -157,4 +151,4 @@ const $2 "count": 20, "includePromotedContent": false -}""".replace(" ", "").replace("\n", "") +}""".replace(" ", "").replace("\n", "") \ No newline at end of file diff --git a/src/formatters.nim b/src/formatters.nim index 29931cf..1fb9b43 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -31,7 +31,8 @@ let illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]" proc getUrlPrefix*(cfg: Config): string = - "https://" & cfg.hostname + if cfg.useHttps: https & cfg.hostname + else: "http://" & cfg.hostname proc shortLink*(text: string; length=28): string = result = text.replace(wwwRegex, "") diff --git a/src/nitter.nim b/src/nitter.nim index 15fa8dd..a299280 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,8 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool import views/[general, about] import routes/[ preferences, timeline, status, media, search, list, #rss, debug, - unsupported, embed, resolver, router_utils, home, follow, twitter_api, - activityspoof] + unsupported, embed, resolver, router_utils, home, follow, twitter_api] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -52,7 +51,6 @@ createEmbedRouter(cfg) #createRssRouter(cfg) #createDebugRouter(cfg) createTwitterApiRouter(cfg) -createActivityPubRouter(cfg) settings: port = Port(cfg.port) @@ -105,6 +103,5 @@ routes: extend resolver, "" extend embed, "" #extend debug, "" - extend activityspoof, "" extend api, "" extend unsupported, "" diff --git a/src/parser.nim b/src/parser.nim index 360b20a..e306388 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -505,41 +505,39 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = if instructions.len == 0: return - for i in instructions: - if i{"type"}.getStr == "TimelineAddEntries": - for e in i{"entries"}: - let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult, true) + for e in instructions[0]{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult, true) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) - if $tweet.id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet - elif entryId.startsWith("tombstone"): - let id = entryId.getId() - let tweet = Tweet( - id: parseBiggestInt(id), - available: false, - text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone - ) + if $tweet.id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet + elif entryId.startsWith("tombstone"): + let id = entryId.getId() + let tweet = Tweet( + id: parseBiggestInt(id), + available: false, + text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone + ) - if id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet - elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"): - let (thread, self) = parseGraphThread(e) - if self: - result.after = thread - else: - result.replies.content.add thread - elif entryId.startsWith("cursor-bottom"): - result.replies.bottom = e{"content", "value"}.getStr + if id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet + elif entryId.startsWith("conversationthread") or entryId.startswith("reply-mixer-conversation"): + let (thread, self) = parseGraphThread(e) + if self: + result.after = thread + else: + result.replies.content.add thread + elif entryId.startsWith("cursor-bottom"): + result.replies.bottom = e{"content", "itemContent", "value"}.getStr proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Profile = result = Profile(tweets: Timeline(beginning: after.len == 0)) diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim deleted file mode 100644 index 6ecdd0a..0000000 --- a/src/routes/activityspoof.nim +++ /dev/null @@ -1,242 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times - -import jester - -import router_utils -import ".."/[types, formatters, api] -import ../views/[mastoapi] - -export json, uri, sequtils, options, sugar, times -export router_utils -export api, formatters -export mastoapi - -proc createActivityPubRouter*(cfg: Config) = - router activityspoof: - get "/api/v1/accounts/?": - resp Http200, {"Content-Type": "application/json"}, """[]""" - - get "/api/v1/statuses/@id": - let id = @"id" - - if id.len > 19 or id.any(c => not c.isDigit): - resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" - - let prefs = cookiePrefs() - - let conv = await getTweet(id) - if conv == nil: - echo "nil conv" - - if conv == nil or conv.tweet == nil or conv.tweet.id == 0: - var error = "Record not found" - if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: - error = conv.tweet.tombstone - - var errJson = newJObject() - errJson["error"] = %error - - resp Http404, {"Content-Type": "application/json"}, $errJson - - let - tweet = conv.tweet - tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{id}" - var media: seq[JsonNode] = @[] - - if tweet.photos.len > 0: - for url in tweet.photos: - let image = getUrlPrefix(cfg) & getPicUrl(url) - var mediaObj = newJObject() - - mediaObj["id"] = %"150745989836308480" # idk if discord even parses this snowflake, but its my user id why not - mediaObj["type"] = %"image" - mediaObj["url"] = %image - mediaObj["preview_url"] = %image - mediaObj["remote_url"] = newJNull() - mediaObj["preview_remote_url"] = newJNull() - mediaObj["text_url"] = newJNull() - mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y) - # FIXME but this probably isnt used by discord - mediaObj["meta"] = newJObject() - - media.add(mediaObj) - - if tweet.video.isSome(): - let - videoObj = get(tweet.video) - vars = videoObj.variants.filterIt(it.contentType == mp4) - var mediaObj = newJObject() - - mediaObj["id"] = %"150745989836308480" - mediaObj["type"] = %"video" - mediaObj["url"] = %vars[^1].url - mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(videoObj.thumb)) - mediaObj["remote_url"] = newJNull() - mediaObj["preview_remote_url"] = newJNull() - mediaObj["text_url"] = newJNull() - mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y) - # FIXME but this probably isnt used by discord - mediaObj["meta"] = newJObject() - - media.add(mediaObj) - elif tweet.gif.isSome(): - let gif = get(tweet.gif) - var mediaObj = newJObject() - - mediaObj["id"] = %"150745989836308480" - mediaObj["type"] = %"video" - mediaObj["url"] = %(&"https://{gif.url}") - mediaObj["preview_url"] = %(getUrlPrefix(cfg) & getPicUrl(gif.thumb)) - mediaObj["remote_url"] = newJNull() - mediaObj["preview_remote_url"] = newJNull() - mediaObj["text_url"] = newJNull() - mediaObj["description"] = newJNull() # FIXME (not used by discord, i like a11y) - # FIXME but this probably isnt used by discord - mediaObj["meta"] = newJObject() - - media.add(mediaObj) - - var postJson = newJObject() - postJson["id"] = %(&"{tweet.id}") - postJson["url"] = %tweetUrl - postJson["uri"] = %tweetUrl - postJson["created_at"] = %($tweet.time) - postJson["edited_at"] = newJNull() - postJson["reblog"] = newJNull() - if tweet.replyId != 0: - postJson["in_reply_to_id"] = %(&"{tweet.replyId}") - postJson["in_reply_to_account_id"] = %"" - else: - postJson["in_reply_to_id"] = newJNull() - postJson["in_reply_to_account_id"] = newJNull() - postJson["language"] = %"en" # FIXME - postJson["content"] = %formatTweetForMastoAPI(tweet, cfg, prefs) - postJson["spoiler_text"] = %"" - postJson["visibility"] = %"public" - postJson["application"] = %*{ - "name": "Nitter", - "website": getUrlPrefix(cfg) - } - postJson["media_attachments"] = %media - postJson["account"] = %*{ - "id": &"{tweet.user.id}", - "display_name": tweet.user.fullname, - "username": tweet.user.username, - "acct": tweet.user.username, - "url": &"{getUrlPrefix(cfg)}/{tweet.user.username}", - "uri": &"{getUrlPrefix(cfg)}/{tweet.user.username}", - "created_at": $tweet.user.joinDate, - "locked": tweet.user.protected, - "bot": false, # TODO? - "discoverable": true, - "indexable": false, - "group": false, - "avatar": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic), - "avatar_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.userPic), - "header": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner), - "header_static": getUrlPrefix(cfg) & getPicUrl(tweet.user.banner), - "followers_count": tweet.user.followers, - "following_count": tweet.user.following, - "statuses_count": tweet.user.tweets, - "hide_collections": false, - "noindex": false, - "emojis": @[], - "roles": @[], - "fields": @[], - } - postJson["mentions"] = newJArray() # TODO: parse? - postJson["tags"] = newJArray() # TODO: parse? - postJson["emojis"] = newJArray() - postJson["card"] = newJNull() - postJson["poll"] = newJNull() # TODO: parse? - - resp Http200, {"Content-Type": "application/json"}, $postJson - - get "/users/@name/statuses/@id": - let id = @"id" - if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": - if id.len > 19 or id.any(c => not c.isDigit): - resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" - - let prefs = cookiePrefs() - - let conv = await getTweet(id) - if conv == nil: - echo "nil conv" - - if conv == nil or conv.tweet == nil or conv.tweet.id == 0: - var error = "Record not found" - if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: - error = conv.tweet.tombstone - - var errJson = newJObject() - errJson["error"] = %error - - resp Http404, {"Content-Type": "application/json"}, $errJson - - let postJson = getActivityStream(conv.tweet, cfg, prefs) - - resp Http200, {"Content-Type": "application/json"}, $postJson - - redirect("/$1/status/$2" % [@"name", @"id"]) - - get "/users/@name": - if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": - let user = await getGraphUser(@"name") - if user.suspended or user.id.len == 0: - resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}""" - - let prefs = cookiePrefs() - - let userJson = getActivityStream(user, cfg, prefs) - - resp Http200, {"Content-Type": "application/json"}, $userJson - - redirect("/" & @"name") - - # might as well - get "/.well-known/nodeinfo": - var nodeinfo = newJObject() - let link: JsonNode = %*{ - "href": &"{getUrlPrefix(cfg)}/nodeinfo/2.1.json", - "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1" - } - var links: seq[JsonNode] = @[] - links.add(link) - - nodeinfo["links"] = %links - - resp Http200, {"Content-Type": "application/json"}, $nodeinfo - - get "/nodeinfo/2.1.json": - var nodeinfo = newJObject() - nodeinfo["version"] = %"2.1" - nodeinfo["software"] = %*{ - "name": "Nitter", - "repository": "https://gitlab.com/Cynosphere/nitter" - } - - var metadata = newJObject() - metadata["features"] = newJArray() - metadata["federation"] = newJObject() - metadata["nodeDescription"] = %"Alternative Twitter front-end (ActivityPub support added for Discord)" - metadata["nodeName"] = %"Nitter" - metadata["private"] = %true - metadata["maintainer"] = %*{ - "name": "Cynthia", - "email": "gamers@riseup.net" - } - - nodeinfo["metadata"] = metadata - nodeinfo["openRegistrations"] = %false - nodeinfo["protocols"] = newJArray() - - var services = newJObject() - services["inbound"] = newJArray() - services["outbound"] = newJArray() - - nodeinfo["services"] = services - nodeinfo["usage"] = newJObject() - - resp Http200, {"Content-Type": "application/json"}, $nodeinfo diff --git a/src/routes/status.nim b/src/routes/status.nim index 47b7871..dc109c3 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -1,16 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only -import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times +import asyncdispatch, strutils, sequtils, uri, options, sugar, strformat import jester, karax/vdom import router_utils import ".."/[types, formatters, api] -import ../views/[general, status, search, mastoapi] +import ../views/[general, status, search] -export json, uri, sequtils, options, sugar, times +export uri, sequtils, options, sugar export router_utils export api, formatters -export status, mastoapi +export status proc createStatusRouter*(cfg: Config) = router status: @@ -39,35 +39,7 @@ proc createStatusRouter*(cfg: Config) = get "/@name/status/@id/?": cond '.' notin @"name" - var id = @"id" - var rawFile = false - if id.endsWith(".mp4"): - rawFile = true - id.removeSuffix(".mp4") - - if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": - if id.len > 19 or id.any(c => not c.isDigit): - resp Http404, {"Content-Type": "application/json"}, """{"error":"Invalid record ID"}""" - - let prefs = cookiePrefs() - - let conv = await getTweet(id) - if conv == nil: - echo "nil conv" - - if conv == nil or conv.tweet == nil or conv.tweet.id == 0: - var error = "Record not found" - if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: - error = conv.tweet.tombstone - - var errJson = newJObject() - errJson["error"] = %error - - resp Http404, {"Content-Type": "application/json"}, $errJson - - let postJson = getActivityStream(conv.tweet, cfg, prefs) - - resp Http200, {"Content-Type": "application/json"}, $postJson + let id = @"id" if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) @@ -106,8 +78,8 @@ proc createStatusRouter*(cfg: Config) = contextUrl = "" if tweet.quote.isSome(): - let - quote = get(tweet.quote) + let + quote = tweet.quote.get() quoteUser = quote.user if tweet.replyId != 0: context = &"↩ Replying to: @{tweet.replyHandle}\nā†˜ Quoting: {quoteUser.fullname} (@{quoteUser.username})" @@ -130,7 +102,7 @@ proc createStatusRouter*(cfg: Config) = elif tweet.gif.isSome(): let gif = get(tweet.gif) images = @[gif.thumb] - video = getUrlPrefix(cfg) & getPicUrl(gif.url) + video = getPicUrl(gif.url) #elif tweet.card.isSome(): # let card = tweet.card.get() # if card.image.len > 0: @@ -138,13 +110,10 @@ proc createStatusRouter*(cfg: Config) = # elif card.video.isSome(): # images = @[card.video.get().thumb] - if rawFile and video != "": - redirect(video) - let html = renderConversation(conv, prefs, getPath() & "#m") resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, images=images, video=video, avatar=avatar, time=time, - context=context, contextUrl=contextUrl, id=id) + context=context, contextUrl=contextUrl) get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index c951747..70fd199 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -1,16 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, sequtils, uri, options, times, json +import asyncdispatch, strutils, sequtils, uri, options, times import jester, karax/vdom import router_utils import ".."/[types, formatters, query, api] -import ../views/[general, profile, timeline, status, search, mastoapi] +import ../views/[general, profile, timeline, status, search] export vdom -export uri, sequtils, json +export uri, sequtils export router_utils export formatters, query, api -export profile, timeline, status, mastoapi +export profile, timeline, status proc getQuery*(request: Request; tab, name: string): Query = case tab @@ -137,18 +137,6 @@ proc createTimelineRouter*(cfg: Config) = of "following": resp renderMain(renderUserList(await getGraphFollowing(await getUserId(@"name"), getCursor()), prefs), request, cfg, prefs) else: - if request.headers.hasKey("Accept") and request.headers["Accept"] == "application/activity+json": - let userId = await getUserId(@"name") - - if userId == "suspended" or userId.len == 0: - resp Http404, {"Content-Type": "application/json"}, """{"error":"User not found"}""" - - let user = await getGraphUser(@"name") - - let userJson = getActivityStream(user, cfg, prefs) - - resp Http200, {"Content-Type": "application/json"}, $userJson - var query = request.getQuery(@"tab", @"name") if names.len != 1: query.fromUser = names diff --git a/src/routes/twitter_api.nim b/src/routes/twitter_api.nim index 666c8a8..ba3507e 100644 --- a/src/routes/twitter_api.nim +++ b/src/routes/twitter_api.nim @@ -138,7 +138,7 @@ proc createTwitterApiRouter*(cfg: Config) = let response = await getUserProfileJson(username) respJson response - #get "/api/user/@id/tweets": + # get "/api/user/@id/tweets": # let id = @"id" # let response = await getUserTweetsJson(id) # respJson response diff --git a/src/views/about.nim b/src/views/about.nim index 4eb3a71..891a610 100644 --- a/src/views/about.nim +++ b/src/views/about.nim @@ -5,7 +5,7 @@ import karax/[karaxdsl, vdom] const date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"") hash = staticExec("git show -s --format=\"%h\"") - link = "https://gitlab.com/Cynosphere/nitter/commit/" & hash + link = "https://gitdab.com/Cynosphere/nitter/commit/" & hash version = &"{date}-{hash}" var aboutHtml: string diff --git a/src/views/general.nim b/src/views/general.nim index 51447e3..bf94569 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -3,7 +3,7 @@ import uri, strutils, strformat, times, options import karax/[karaxdsl, vdom] import renderutils -import ".."/[utils, types, prefs, formatters] +import ../utils, ../types, ../prefs, ../formatters import jester @@ -39,7 +39,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; rss=""; canonical=""; avatar=""; context=""; contextUrl=""; - id=""; time: Option[DateTime] = none(DateTime)): VNode = + time: Option[DateTime] = none(DateTime)): VNode = var theme = prefs.theme.toTheme if "theme" in req.params: theme = req.params["theme"].toTheme @@ -62,9 +62,9 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) - link(rel="icon", type="image/png", sizes="32x32", href=(&"{getUrlPrefix(cfg)}/favicon-32x32.png")) - link(rel="icon", type="image/png", sizes="16x16", href=(&"{getUrlPrefix(cfg)}/favicon-16x16.png")) link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png") + link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png") + link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png") link(rel="manifest", href="/site.webmanifest") link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60") link(rel="search", type="application/opensearchdescription+xml", title=cfg.title, @@ -104,9 +104,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; var siteName = "Nitter" - let isDiscord = req.headers.getOrDefault("User-Agent").toString().contains("Discordbot") - - if time.isSome and not isDiscord: + if time.isSome: let timeObj = time.get let timeStr = $timeObj meta(property="og:article:published_time", content=timeStr) @@ -157,14 +155,12 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if contextUrl != "": url = contextUrl - link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed") + verbatim &"" elif context != "" and contextUrl != "": var title = encodeUrl(finalizedTitleText) author = encodeUrl(context) - link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(contextUrl)}"), type="application/json+oembed") - - link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/users/i/statuses/{id}"), type="application/activity+json") + verbatim &"" # this is last so images are also preloaded # if this is done earlier, Chrome only preloads one image for some reason @@ -174,14 +170,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; images: seq[string] = @[]; banner=""; avatar=""; context=""; - contextUrl=""; id=""; time: Option[DateTime] = none(DateTime) + contextUrl = ""; time: Option[DateTime] = none(DateTime) ): string = let canonical = getTwitterLink(req.path, req.params) let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, canonical, avatar, context, contextUrl, id, time) + rss, canonical, avatar, context, contextUrl, time) body: renderNavbar(cfg, req, rss, canonical) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim deleted file mode 100644 index bc57301..0000000 --- a/src/views/mastoapi.nim +++ /dev/null @@ -1,178 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -import strutils, strformat, options, json, sequtils, times -import ".."/[types, formatters, utils] - -proc formatTweetForMastoAPI*(tweet: Tweet, cfg: Config, prefs: Prefs): string = - var content = replaceUrls(tweet.text, prefs) - - if tweet.quote.isSome(): - let - quote = get(tweet.quote) - quoteContent = replaceUrls(quote.text, prefs) - quoteUrl = &"{getUrlPrefix(cfg)}/i/status/{quote.id}" - content &= &"\n\n
ā†˜ {quote.user.fullName} (@{quote.user.username})\n{quoteContent}" - - if quote.video.isSome() or quote.gif.isSome(): - content &= "\nšŸ“¹" - if quote.gif.isSome(): - content &= " (GIF)" - elif quote.photos.len > 0: - content &= "\nšŸ–¼ļø" - if quote.photos.len > 1: - content &= &" ({quote.photos.len})" - - content &= "
" - - if tweet.birdwatch.isSome(): - let - note = get(tweet.birdwatch) - noteContent = replaceUrls(note.text, prefs) - content &= &"\n
ā“˜ {note.title}\n{noteContent}
" - - result = content.replace("\n", "
") - -proc getActivityStream*(tweet: Tweet, cfg: Config, prefs: Prefs): JsonNode = - let - tweetUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.id}" - tweetContent = formatTweetForMastoAPI(tweet, cfg, prefs) - var media: seq[JsonNode] = @[] - - if tweet.photos.len > 0: - for url in tweet.photos: - let image = getUrlPrefix(cfg) & getPicUrl(url) - var mediaObj = newJObject() - - mediaObj["type"] = %"Document" - mediaObj["mediaType"] = %"image/png" - mediaObj["url"] = %image - mediaObj["name"] = newJNull() # FIXME a11y - - media.add(mediaObj) - - if tweet.video.isSome(): - let - videoObj = get(tweet.video) - vars = videoObj.variants.filterIt(it.contentType == mp4) - var mediaObj = newJObject() - - mediaObj["type"] = %"Document" - mediaObj["mediaType"] = %"video/mp4" - mediaObj["url"] = %vars[^1].url - mediaObj["name"] = newJNull() # FIXME a11y - - media.add(mediaObj) - elif tweet.gif.isSome(): - let gif = get(tweet.gif) - var mediaObj = newJObject() - - mediaObj["type"] = %"Document" - mediaObj["mediaType"] = %"video/mp4" - mediaObj["url"] = %(&"https://{gif.url}") - mediaObj["name"] = newJNull() # FIXME a11y - - media.add(mediaObj) - - var context: seq[JsonNode] = @[] - let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams" - context.add(contextUrl) - let asProps: JsonNode = %*{ - "ostatus": "http://ostatus.org#", - "atomUri": "ostatus:atomUri", - "inReplyToAtomUri": "ostatus:inReplyToAtomUri", - "conversation": "ostatus:conversation", - "sensitive": "as:sensitive", - } - context.add(asProps) - - var postJson = newJObject() - postJson["@context"] = %context - postJson["id"] = %tweetUrl - postJson["type"] = %"Note" - postJson["summary"] = newJNull() - if tweet.replyId != 0: - let replyUrl = &"{getUrlPrefix(cfg)}/i/status/{tweet.replyId}" - postJson["inReplyTo"] = %replyUrl - postJson["inReplyToAtomUri"] = %replyUrl - else: - postJson["inReplyTo"] = newJNull() - postJson["inReplyToAtomUri"] = newJNull() - postJson["published"] = %($tweet.time) - postJson["url"] = %tweetUrl - postJson["attributedTo"] = %(&"{getUrlPrefix(cfg)}/users/{tweet.user.username}") - postJson["to"] = newJArray() - postJson["cc"] = %(@["https://www.w3.org/ns/activitystreams#Public"]) - postJson["sensitive"] = %false # FIXME - postJson["atomUri"] = %tweetUrl - postJson["conversation"] = %"" - postJson["content"] = %tweetContent - postJson["contentMap"] = %*{ - "en": tweetContent - } - postJson["attachment"] = %media - postJson["tag"] = newJArray() # TODO: parse? - postJson["replies"] = newJObject() - - result = postJson - -proc getActivityStream*(user: User, cfg: Config, prefs: Prefs): JsonNode = - let userUrl = &"{getUrlPrefix(cfg)}/{user.username}" - - var context: seq[JsonNode] = @[] - let contextUrl: JsonNode = %"https://www.w3.org/ns/activitystreams" - context.add(contextUrl) - let contextUrl2: JsonNode = %"https://w3id.org/security/v1" - context.add(contextUrl2) - - let contextAka: JsonNode = %*{ - "@id": "as:alsoKnownAs", - "@type": "@id" - } - let contextMovedTo = %*{ - "@id": "as:movedTo", - "@type": "@id" - } - var asProps: JsonNode = %*{ - "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", - "schema": "http://schema.org#", - "PropertyValue": "schema:PropertyValue", - "value": "schema:value", - } - asProps["alsoKnownAs"] = contextAka - asProps["movedTo"] = contextMovedTo - context.add(asProps) - - var userJson = newJObject() - userJson["@context"] = %context - userJson["id"] = %userUrl - userJson["type"] = %"Person" - userJson["following"] = %(userUrl & "/following") - userJson["followers"] = %(userUrl & "/followers") - userJson["inbox"] = newJNull() - userJson["outbox"] = newJNull() - userJson["featured"] = newJNull() - userJson["featuredTags"] = newJNull() - userJson["preferredUsername"] = %user.username - userJson["name"] = %user.fullname - userJson["summary"] = %user.bio - userJson["url"] = %userUrl - userJson["manuallyApprovesFollowers"] = %user.protected - userJson["discoverable"] = %true - userJson["indexable"] = %false - userJson["published"] = %($user.joinDate) - userJson["memorial"] = %false - userJson["publicKey"] = newJNull() - userJson["tag"] = newJArray() - userJson["attachment"] = newJArray() - userJson["endpoints"] = newJObject() - userJson["icon"] = %*{ - "type": "Image", - "mediaType": "image/jpeg", - "url": getUrlPrefix(cfg) & getPicUrl(user.userPic) - } - userJson["image"] = %*{ - "type": "Image", - "mediaType": "image/jpeg", - "url": getUrlPrefix(cfg) & getPicUrl(user.banner) - } - - result = userJson diff --git a/src/views/search.nim b/src/views/search.nim index 8dee8e6..abcc236 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -24,9 +24,9 @@ proc renderSearch*(): VNode = buildHtml(tdiv(class="panel-container")): tdiv(class="search-bar"): form(`method`="get", action="/search", autocomplete="off"): - hiddenField("f", "tweets") + hiddenField("f", "users") input(`type`="text", name="q", autofocus="", - placeholder="Search...", dir="auto") + placeholder="Enter username...", dir="auto") button(`type`="submit"): icon "search" proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode = diff --git a/src/views/tweet.nim b/src/views/tweet.nim index d986097..1603b2a 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -351,7 +351,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.quote.isSome: renderQuote(tweet.quote.get(), prefs, path) - if tweet.birdwatch.isSome: + if mainTweet and tweet.birdwatch.isSome: renderCommunityNote(tweet.birdwatch.get(), prefs) if mainTweet: