about summary refs log tree commit diff
path: root/bskyweb
diff options
context:
space:
mode:
authorCaidan Williams <caidan@internet.dev>2025-09-09 20:03:50 -0700
committerCaidan Williams <caidan@internet.dev>2025-09-09 20:03:50 -0700
commitf5079b8158d87073222f834dfb40039b99650a27 (patch)
tree291336ab6aa34febd7687e0ad28a3b50e3ed0083 /bskyweb
parentc7591ef058be0456f9fe122b686aa1c1f1c6e966 (diff)
downloadvoidsky-f5079b8158d87073222f834dfb40039b99650a27.tar.zst
feat: add OpenGraph metadata for feed URLs in bskyweb
Enable rich link previews when feed URLs are shared in iMessage, Slack, and other social platforms. Adds feed title, description, creator info, and avatar images to improve sharing experience.
Diffstat (limited to 'bskyweb')
-rw-r--r--bskyweb/cmd/bskyweb/server.go53
-rw-r--r--bskyweb/templates/feed.html54
2 files changed, 106 insertions, 1 deletions
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index f305f0d3c..8d75bb6ef 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -313,7 +313,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
 	e.GET("/profile/:handleOrDID/search", server.WebGeneric)
 	e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
-	e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
+	e.GET("/profile/:handleOrDID/feed/:rkey", server.WebFeed)
 	e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
 	e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric)
 
@@ -603,6 +603,57 @@ func (srv *Server) WebProfile(c echo.Context) error {
 	return c.Render(http.StatusOK, "profile.html", data)
 }
 
+func (srv *Server) WebFeed(c echo.Context) error {
+	ctx := c.Request().Context()
+	data := srv.NewTemplateContext()
+
+	// sanity check arguments. don't 4xx, just let app handle if not expected format
+	rkeyParam := c.Param("rkey")
+	rkey, err := syntax.ParseRecordKey(rkeyParam)
+	if err != nil {
+		return c.Render(http.StatusOK, "feed.html", data)
+	}
+	handleOrDIDParam := c.Param("handleOrDID")
+	handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
+	if err != nil {
+		return c.Render(http.StatusOK, "feed.html", data)
+	}
+
+	identifier := handleOrDID.Normalize().String()
+
+	// requires two fetches: first fetch profile to get DID
+	pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
+	if err != nil {
+		log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
+		return c.Render(http.StatusOK, "feed.html", data)
+	}
+	unauthedViewingOkay := true
+	for _, label := range pv.Labels {
+		if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
+			unauthedViewingOkay = false
+		}
+	}
+
+	if !unauthedViewingOkay {
+		return c.Render(http.StatusOK, "feed.html", data)
+	}
+	did := pv.Did
+	data["did"] = did
+
+	// then fetch the feed generator
+	feedURI := fmt.Sprintf("at://%s/app.bsky.feed.generator/%s", did, rkey)
+	fgv, err := appbsky.FeedGetFeedGenerator(ctx, srv.xrpcc, feedURI)
+	if err != nil {
+		log.Warnf("failed to fetch feed generator: %s\t%v", feedURI, err)
+		return c.Render(http.StatusOK, "feed.html", data)
+	}
+	req := c.Request()
+	data["feedView"] = fgv.View
+	data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
+
+	return c.Render(http.StatusOK, "feed.html", data)
+}
+
 type IPCCRequest struct {
 	IP string `json:"ip"`
 }
diff --git a/bskyweb/templates/feed.html b/bskyweb/templates/feed.html
new file mode 100644
index 000000000..716a2c65c
--- /dev/null
+++ b/bskyweb/templates/feed.html
@@ -0,0 +1,54 @@
+{% extends "base.html" %}
+
+{% block head_title %}
+{%- if feedView -%}
+  {{ feedView.DisplayName }} by @{{ feedView.Creator.Handle }} | Bluesky Feed
+{%- else -%}
+  Bluesky
+{%- endif -%}
+{% endblock %}
+
+{% block html_head_extra -%}
+{%- if feedView -%}
+  <meta property="og:site_name" content="Bluesky Social">
+  <meta property="og:type" content="website">
+  {%- if requestURI %}
+  <meta property="og:url" content="{{ requestURI }}">
+  <link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
+  {% endif -%}
+  
+  {%- if feedView.DisplayName %}
+  <meta property="og:title" content="{{ feedView.DisplayName }} by @{{ feedView.Creator.Handle }}">
+  {% else %}
+  <meta property="og:title" content="Feed by @{{ feedView.Creator.Handle }}">
+  {% endif -%}
+  
+  {%- if feedView.Description %}
+  <meta name="description" content="{{ feedView.Description }}">
+  <meta property="og:description" content="{{ feedView.Description }}">
+  <meta property="twitter:description" content="{{ feedView.Description }}">
+  {% endif -%}
+  
+  {%- if feedView.Avatar %}
+  <meta property="og:image" content="{{ feedView.Avatar }}">
+  <meta property="twitter:image" content="{{ feedView.Avatar }}">
+  <meta name="twitter:card" content="summary">
+  {% endif %}
+  
+  <meta name="twitter:label1" content="Created by">
+  <meta name="twitter:value1" content="@{{ feedView.Creator.Handle }}">
+  
+  <link rel="alternate" href="{{ feedView.Uri }}" />
+{% endif -%}
+{%- endblock %}
+
+{% block noscript_extra -%}
+{%- if feedView -%}
+<div id="bsky_feed_summary">
+  <h3>Feed</h3>
+  <p id="bsky_feed_name">{{ feedView.DisplayName }}</p>
+  <p id="bsky_feed_creator">{{ feedView.Creator.Handle }}</p>
+  <p id="bsky_feed_description">{{ feedView.Description }}</p>
+</div>
+{% endif -%}
+{%- endblock %}
\ No newline at end of file