diff --git a/src/formatters.nim b/src/formatters.nim index 1fb9b43..29931cf 100644 --- a/src/formatters.nim +++ b/src/formatters.nim @@ -31,8 +31,7 @@ let illegalXmlRegex = re"(*UTF8)[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]" proc getUrlPrefix*(cfg: Config): string = - if cfg.useHttps: https & cfg.hostname - else: "http://" & cfg.hostname + "https://" & cfg.hostname proc shortLink*(text: string; length=28): string = result = text.replace(wwwRegex, "") diff --git a/src/nitter.nim b/src/nitter.nim index a299280..15fa8dd 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,8 @@ 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] + unsupported, embed, resolver, router_utils, home, follow, twitter_api, + activityspoof] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -51,6 +52,7 @@ createEmbedRouter(cfg) #createRssRouter(cfg) #createDebugRouter(cfg) createTwitterApiRouter(cfg) +createActivityPubRouter(cfg) settings: port = Port(cfg.port) @@ -103,5 +105,6 @@ routes: extend resolver, "" extend embed, "" #extend debug, "" + extend activityspoof, "" extend api, "" extend unsupported, "" diff --git a/src/routes/activityspoof.nim b/src/routes/activityspoof.nim new file mode 100644 index 0000000..c853554 --- /dev/null +++ b/src/routes/activityspoof.nim @@ -0,0 +1,242 @@ +# 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"] = %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://gitdab.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 dc109c3..7ca5322 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -1,16 +1,16 @@ # SPDX-License-Identifier: AGPL-3.0-only -import asyncdispatch, strutils, sequtils, uri, options, sugar, strformat +import json, asyncdispatch, strutils, sequtils, uri, options, sugar, strformat, times import jester, karax/vdom import router_utils import ".."/[types, formatters, api] -import ../views/[general, status, search] +import ../views/[general, status, search, mastoapi] -export uri, sequtils, options, sugar +export json, uri, sequtils, options, sugar, times export router_utils export api, formatters -export status +export status, mastoapi proc createStatusRouter*(cfg: Config) = router status: @@ -41,6 +41,30 @@ proc createStatusRouter*(cfg: Config) = cond '.' notin @"name" 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 + if id.len > 19 or id.any(c => not c.isDigit): resp Http404, showError("Invalid tweet ID", cfg) @@ -78,8 +102,8 @@ proc createStatusRouter*(cfg: Config) = contextUrl = "" if tweet.quote.isSome(): - let - quote = tweet.quote.get() + let + quote = get(tweet.quote) quoteUser = quote.user if tweet.replyId != 0: context = &"↩ Replying to: @{tweet.replyHandle}\n↘ Quoting: {quoteUser.fullname} (@{quoteUser.username})" @@ -113,7 +137,37 @@ proc createStatusRouter*(cfg: Config) = 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) + context=context, contextUrl=contextUrl, id=id) + + get "/@name/status/@id.mp4": + cond '.' notin @"name" + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + + let conv = await getTweet(id, getCursor()) + if conv == nil: + echo "nil conv" + + if conv == nil or conv.tweet == nil or conv.tweet.id == 0: + var error = "Tweet not found" + if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: + error = conv.tweet.tombstone + resp Http404, showError(error, cfg) + + let tweet = conv.tweet + + if tweet.video.isSome(): + let videoObj = get(tweet.video) + let vars = videoObj.variants.filterIt(it.contentType == mp4) + redirect(vars[^1].url) + elif tweet.gif.isSome(): + let gif = get(tweet.gif) + let url = getPicUrl(gif.url) + redirect(url) + + redirect("/$1/status/$2" % [@"name", @"id"]) get "/@name/@s/@id/@m/?@i?": cond @"s" in ["status", "statuses"] diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 70fd199..c951747 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 +import asyncdispatch, strutils, sequtils, uri, options, times, json import jester, karax/vdom import router_utils import ".."/[types, formatters, query, api] -import ../views/[general, profile, timeline, status, search] +import ../views/[general, profile, timeline, status, search, mastoapi] export vdom -export uri, sequtils +export uri, sequtils, json export router_utils export formatters, query, api -export profile, timeline, status +export profile, timeline, status, mastoapi proc getQuery*(request: Request; tab, name: string): Query = case tab @@ -137,6 +137,18 @@ 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 ba3507e..666c8a8 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/general.nim b/src/views/general.nim index bf94569..a100e0d 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=""; - time: Option[DateTime] = none(DateTime)): VNode = + id=""; time: Option[DateTime] = none(DateTime)): VNode = var theme = prefs.theme.toTheme if "theme" in req.params: theme = req.params["theme"].toTheme @@ -63,8 +63,8 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) 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="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="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,13 +104,15 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; var siteName = "Nitter" - if time.isSome: - let timeObj = time.get - let timeStr = $timeObj - meta(property="og:article:published_time", content=timeStr) + #let isDiscord = req.headers.hasKey("User-Agent") and req.headers["User-Agent"].contains("Discordbot") - let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") - siteName = &"Nitter • {formattedTime}" + #if time.isSome and not isDiscord: + # let timeObj = time.get + # let timeStr = $timeObj + # meta(property="og:article:published_time", content=timeStr) + # + # let formattedTime = timeObj.format("yyyy/MM/dd HH:mm:ss") + # siteName = &"Nitter • {formattedTime}" meta(property="og:site_name", content=siteName) @@ -155,12 +157,14 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if contextUrl != "": url = contextUrl - verbatim &"" + link(rel="alternate", href=(&"{getUrlPrefix(cfg)}/oembed.json?type=video&provider={encodeUrl(siteName)}&title={title}&user={author}&url={encodeUrl(url)}"), type="application/json+oembed") elif context != "" and contextUrl != "": var title = encodeUrl(finalizedTitleText) author = encodeUrl(context) - verbatim &"" + 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") # this is last so images are also preloaded # if this is done earlier, Chrome only preloads one image for some reason @@ -170,14 +174,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 = ""; time: Option[DateTime] = none(DateTime) + contextUrl=""; id=""; 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, time) + rss, canonical, avatar, context, contextUrl, id, time) body: renderNavbar(cfg, req, rss, canonical) diff --git a/src/views/mastoapi.nim b/src/views/mastoapi.nim new file mode 100644 index 0000000..fa1bbfa --- /dev/null +++ b/src/views/mastoapi.nim @@ -0,0 +1,178 @@ +# 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 tweet.gif.isSome(): + content &= "\n📹" + if tweet.gif.isSome(): + content &= " (GIF)" + elif tweet.photos.len > 0: + content &= "\n🖼️" + if tweet.photos.len > 1: + content &= &" ({tweet.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"] = %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/tweet.nim b/src/views/tweet.nim index 1603b2a..d986097 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 mainTweet and tweet.birdwatch.isSome: + if tweet.birdwatch.isSome: renderCommunityNote(tweet.birdwatch.get(), prefs) if mainTweet: