clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat(social): unify feeds, profiles and podcast subscriptions

Dami committed ca0878f
Add Social navigation for notes, posts, photos, videos and podcasts with author-specific profile views and pagination.

Expose RSS audio enclosures for relay-wide and author-specific podcast subscriptions, including metadata-only attachments.

Cover category filtering, profile access, tenant links and RSS output with ordinary regression tests.
commit
ca0878fb21756636b0f54ac31ca938c40e901b51
parent
3d3a0772f99f7cd59282585ac5ed94b2cbfdf180
author
Dami <dami.etoile@gmail.com>
24 files changed, +1155 -32
Mdocs/social.md20+++-
Minternal/daemon/social_browse.go7+-
Minternal/daemon/social_browse_test.go59+++++++++++
Ainternal/daemon/social_categories.go167++++++++++++++++++++++++++++++
Ainternal/daemon/social_categories_test.go58++++++++++
Mtinyclient/mobile_header.go20++++
Mtinyclient/page.html9+-
Atinyclient/podcast_rss.go167++++++++++++++++++++++++++++++
Atinyclient/podcast_rss_test.go138+++++++++++++++++++++++++
Mtinyclient/social.html25+++--
Mtinyclient/social_content.go27++++-
Mtinyclient/social_content_test.go11++
Mtinyclient/social_mentions.go2+-
Mtinyclient/social_mentions_test.go2+-
Atinyclient/social_navigation.go68++++++++++++
Atinyclient/social_navigation_test.go50+++++++++
Mtinyclient/social_pages.go26++++-
Mtinyclient/social_pages_test.go2+-
Atinyclient/social_prefix_test.go59+++++++++++
Atinyclient/social_profile.go90++++++++++++++++
Atinyclient/social_profile.html19+++
Atinyclient/social_profile_test.go129+++++++++++++++++++++++
Mtinyclient/style.css23++++-
Mtinyclient/templates.go9+-
M docs/social.md +17 -3
@@ -1,10 +1,24 @@
11 # Social22 3-**Social** is the relay's chronological publishing space. It brings short notes and long-form articles into one feed, with replies and reaction counts folded into each post. Every item remains a normal Nostr event that other clients can read.3+**Social** brings notes, longer posts and media into one chronological feed, with replies and reaction counts folded into each post. Use the Social navigation to browse Feed, Notes, Posts, Photos, Videos, Podcasts or Profile.4+5+Feed shows everything together. Notes collects short updates, while Posts collects long-form articles. Photos, Videos and Podcasts show posts with matching media attachments or links. A post can appear in more than one media view.6+7+## Profiles8+9+Choose **Profile** to see your own profile and posts. Select an author's name on any post to see theirs, or enter a public key or npub on the profile page. The profile's filters narrow that person's posts by type.10+11+You can read profiles without signing in when the relay allows public reads. Sign in to open your own profile or use **Edit profile** to change your name, picture and other details.12+13+## Podcast subscriptions14+15+Choose **Subscribe with RSS** in Podcasts to copy the feed address into a podcast app or RSS reader. Each profile also offers a feed for that person's audio posts. Episodes include an audio enclosure so readers can stream or download the recording.16+17+The relay-wide feed is `/social/podcasts.rss`. Add `?author=<pubkey>` to subscribe to one person. Public subscriptions work without signing in; private relays retain their normal access requirements.418 519 ## Notes and articles620 7-A note is a regular kind 1 text note. It is suited to short updates and microblogging. An article is a replaceable kind 30023 event with a `d` tag, a `title` tag and Markdown content. Articles can also carry `summary` and `image` tags for previews. The relay shows notes and articles newest first, and offers separate Notes and Articles views when you want to narrow the feed.21+A note is a regular kind 1 text note. It is suited to short updates and microblogging. An article is a replaceable kind 30023 event with a `d` tag, a `title` tag and Markdown content. Articles can also carry `summary` and `image` tags for previews. The Posts view shows these articles newest first.822 923 The web page supports writing either form, previewing article Markdown, and adding media. The relay keeps the signed event unchanged, so a Nostr client can publish to or read from the same feed. See [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md), [NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md) and [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md).1024
@@ -26,7 +40,7 @@ Articles use Markdown with the relay's safe text renderer. Notes and comments re
2640 2741 ## Routes and feeds2842 29-Use `/social` for the mixed feed and `/social/<event-id>` for a post and its conversation. An addressable article can also be opened with `/social?address=30023:<pubkey>:<d-tag>`. `/articles` redirects to Social, while `/articles.json` and `/feed.xml` remain available for existing article readers. The mixed feed is available as `/social.json` and `/social.xml`.43+Use `/social` for the mixed feed and `/social/<event-id>` for a post and its conversation. `/social/profile` opens your profile; add `?author=<pubkey>` to view another person. An addressable article can also be opened with `/social?address=30023:<pubkey>:<d-tag>`. `/articles` redirects to Social, while `/articles.json` and `/feed.xml` remain available for existing article readers. The mixed feed is available as `/social.json` and `/social.xml`.3044 3145 The browser keeps unfinished composer text as a local draft for the signed-in account and relay. Drafts stay in that browser until published or cleared. Preview requests may send the current text to the relay for rendering, but a draft is not published until you submit it.3246
M internal/daemon/social_browse.go +4 -3
M internal/daemon/social_browse_test.go +59 -0
@@ -71,6 +71,65 @@ func TestSocialBrowseDoesNotExposeUnrelatedComments(t *testing.T) {
7171 }7272 }7373 74+func TestSocialBrowseCategoriesFilterMediaAuthorsAndPages(t *testing.T) {75+ _, tenant := testTenant(t)76+ owner := tenant.Policy().Owner77+ authorSecret := strings.Repeat("1", 63) + "2"78+ otherSecret := strings.Repeat("3", 63) + "4"79+ photoOld := signedEvent(t, authorSecret, 1, 100, [][]string{{"imeta", "url https://files.example/old.jpg", "m image/jpeg"}}, "old photo")80+ note := signedEvent(t, authorSecret, 1, 200, nil, "plain note")81+ video := signedEvent(t, otherSecret, 1, 300, [][]string{{"imeta", "url https://files.example/clip.mp4", "m video/mp4"}}, "video")82+ photoNew := signedEvent(t, authorSecret, 30023, 400, [][]string{{"d", "photo"}, {"image", "https://files.example/new.png"}}, "new photo")83+ podcast := signedEvent(t, authorSecret, 30023, 500, [][]string{{"d", "podcast"}, {"imeta", "url https://files.example/episode.mp3", "m audio/mpeg"}}, "episode")84+ for _, row := range []event.Event{photoOld, note, video, photoNew, podcast} {85+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {86+ t.Fatal(err)87+ }88+ }89+ for _, tc := range []struct {90+ name, kind string91+ want []string92+ }{93+ {"photos", "photos", []string{photoNew.ID, photoOld.ID}},94+ {"videos", "videos", []string{video.ID}},95+ {"podcasts", "podcasts", []string{podcast.ID}},96+ {"posts", "posts", []string{podcast.ID, photoNew.ID}},97+ } {98+ t.Run(tc.name, func(t *testing.T) {99+ value, err := tenant.Execute(context.Background(), owner, "browsesocial", []json.RawMessage{rawJSON(map[string]any{"kind": tc.kind, "limit": 20})})100+ if err != nil {101+ t.Fatal(err)102+ }103+ items := value.(map[string]any)["items"].([]socialItem)104+ if len(items) != len(tc.want) {105+ t.Fatalf("items = %#v", items)106+ }107+ for i, want := range tc.want {108+ if items[i].ID != want {109+ t.Fatalf("item %d = %s, want %s", i, items[i].ID, want)110+ }111+ }112+ })113+ }114+ value, err := tenant.Execute(context.Background(), owner, "browsesocial", []json.RawMessage{rawJSON(map[string]any{"kind": "photos", "author": photoOld.PubKey, "limit": 1})})115+ if err != nil {116+ t.Fatal(err)117+ }118+ page := value.(map[string]any)119+ items := page["items"].([]socialItem)120+ if len(items) != 1 || items[0].ID != photoNew.ID || page["next_cursor"] == "" {121+ t.Fatalf("first author page = %#v", page)122+ }123+ value, err = tenant.Execute(context.Background(), owner, "browsesocial", []json.RawMessage{rawJSON(map[string]any{"kind": "photos", "author": photoOld.PubKey, "limit": 1, "cursor": page["next_cursor"]})})124+ if err != nil {125+ t.Fatal(err)126+ }127+ items = value.(map[string]any)["items"].([]socialItem)128+ if len(items) != 1 || items[0].ID != photoOld.ID {129+ t.Fatalf("second author page = %#v", items)130+ }131+}132+74133 func TestSocialBrowseNIP10NIP22AndRevisionReactions(t *testing.T) {75134 _, tenant := testTenant(t)76135 owner := tenant.Policy().Owner
A internal/daemon/social_categories.go +167 -0
@@ -0,0 +1,167 @@
1+package daemon2+3+import (4+ "net/url"5+ "path"6+ "regexp"7+ "strings"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/views"11+)12+13+var socialContentURL = regexp.MustCompile(`https?://[^\s)<>\]"']+`)14+var socialMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)15+16+// socialCategoryMatches applies the categories used by the Social navigation17+// to the same root notes and long-form posts as the main feed. Media is read18+// from NIP-92 metadata first, with URL extensions retained for older events.19+func socialCategoryMatches(row event.Event, category string) bool {20+ switch category {21+ case "", "all", "feed", "notes", "articles", "posts":22+ return true23+ case "photos":24+ return socialHasMedia(row, "image")25+ case "videos":26+ return socialHasMedia(row, "video")27+ case "podcasts":28+ return socialHasMedia(row, "audio")29+ default:30+ return false31+ }32+}33+34+func socialHasMedia(row event.Event, family string) bool {35+ knownURLs := map[string]bool{}36+ for _, tag := range row.Tags {37+ if len(tag) < 2 {38+ continue39+ }40+ if tag[0] == "image" {41+ if socialHTTPURL(tag[1]) {42+ knownURLs[strings.TrimSpace(tag[1])] = true43+ if family == "image" {44+ return true45+ }46+ }47+ continue48+ }49+ if tag[0] != "imeta" {50+ continue51+ }52+ var raw, mime string53+ for _, field := range tag[1:] {54+ if strings.HasPrefix(field, "m ") {55+ mime = strings.TrimSpace(strings.TrimPrefix(field, "m "))56+ }57+ if strings.HasPrefix(field, "url ") {58+ raw = strings.TrimSpace(strings.TrimPrefix(field, "url "))59+ }60+ }61+ if socialHTTPURL(raw) {62+ knownURLs[raw] = true63+ if mediaFamily := socialMediaMIMEFamily(mime); mediaFamily != "" {64+ if mediaFamily == family {65+ return true66+ }67+ } else if socialMediaURLFamily(raw) == family {68+ return true69+ }70+ }71+ }72+ content := socialContentOutsideFences(row.Content)73+ if row.Kind == 30023 {74+ for _, raw := range socialMarkdownImage.FindAllStringSubmatch(content, -1) {75+ if len(raw) > 1 && socialHTTPURL(raw[1]) && !knownURLs[strings.TrimSpace(raw[1])] && family == "image" {76+ return true77+ }78+ }79+ }80+ for _, raw := range socialContentURL.FindAllString(content, -1) {81+ if !knownURLs[strings.TrimSpace(raw)] && socialMediaURLFamily(raw) == family {82+ return true83+ }84+ }85+ return false86+}87+88+func socialMediaMIMEFamily(mime string) string {89+ mime = strings.ToLower(strings.TrimSpace(strings.SplitN(mime, ";", 2)[0]))90+ switch {91+ case strings.HasPrefix(mime, "image/") && mime != "image/svg+xml":92+ return "image"93+ case strings.HasPrefix(mime, "video/"):94+ return "video"95+ case strings.HasPrefix(mime, "audio/"):96+ return "audio"97+ default:98+ return ""99+ }100+}101+102+func socialMediaURLFamily(raw string) string {103+ raw = strings.TrimRight(strings.TrimSpace(raw), ".,;:!?")104+ parsed, err := url.Parse(raw)105+ if err != nil || !socialHTTPURL(raw) || parsed.Hostname() == "" {106+ return ""107+ }108+ switch strings.ToLower(path.Ext(parsed.Path)) {109+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".apng":110+ return "image"111+ case ".mp4", ".m4v", ".webm", ".mov":112+ return "video"113+ case ".mp3", ".m4a", ".wav", ".ogg", ".opus":114+ return "audio"115+ default:116+ return ""117+ }118+}119+120+func socialHTTPURL(raw string) bool {121+ parsed, err := url.Parse(strings.TrimSpace(raw))122+ return err == nil && parsed.User == nil && parsed.Hostname() != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")123+}124+125+func socialContentOutsideFences(content string) string {126+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")127+ var out []string128+ marker := ""129+ for _, line := range lines {130+ if marker != "" {131+ if views.ClosesFence(line, marker) {132+ marker = ""133+ }134+ continue135+ }136+ if next, _, ok := views.OpenFence(line); ok {137+ marker = next138+ continue139+ }140+ out = append(out, socialContentOutsideInlineCode(line))141+ }142+ return strings.Join(out, "\n")143+}144+145+func socialContentOutsideInlineCode(line string) string {146+ var out strings.Builder147+ for len(line) > 0 {148+ start := strings.IndexByte(line, '`')149+ if start < 0 {150+ out.WriteString(line)151+ break152+ }153+ out.WriteString(line[:start])154+ run := 1155+ for start+run < len(line) && line[start+run] == '`' {156+ run++157+ }158+ marker := strings.Repeat("`", run)159+ end := strings.Index(line[start+run:], marker)160+ if end < 0 {161+ out.WriteString(line[start:])162+ break163+ }164+ line = line[start+run+end+run:]165+ }166+ return out.String()167+}
A internal/daemon/social_categories_test.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/tinyrelay/internal/event"7+)8+9+func TestSocialCategoriesRespectMediaMetadataAndURLs(t *testing.T) {10+ for _, tc := range []struct {11+ name, category string12+ row event.Event13+ want bool14+ }{15+ {16+ name: "extensionless image tag", category: "photos",17+ row: event.Event{Kind: 1, Tags: [][]string{{"image", "https://cdn.example/photo"}}}, want: true,18+ },19+ {20+ name: "mime wins over extension", category: "photos",21+ row: event.Event{Kind: 1, Tags: [][]string{{"imeta", "url https://cdn.example/audio.mp3", "m image/jpeg"}}}, want: true,22+ },23+ {24+ name: "mime wins over extension", category: "podcasts",25+ row: event.Event{Kind: 1, Tags: [][]string{{"imeta", "url https://cdn.example/photo.jpg", "m audio/mpeg"}}}, want: true,26+ },27+ {28+ name: "metadata URL does not regain extension family", category: "podcasts",29+ row: event.Event{Kind: 1, Content: "https://cdn.example/audio.mp3", Tags: [][]string{{"imeta", "url https://cdn.example/audio.mp3", "m image/jpeg"}}}, want: false,30+ },31+ {32+ name: "cover URL keeps image family", category: "podcasts",33+ row: event.Event{Kind: 30023, Content: "https://cdn.example/cover.mp3", Tags: [][]string{{"image", "https://cdn.example/cover.mp3"}}}, want: false,34+ },35+ {36+ name: "invalid imeta URL", category: "photos",37+ row: event.Event{Kind: 1, Tags: [][]string{{"imeta", "url https://user:pass@cdn.example/photo.jpg", "m image/jpeg"}}}, want: false,38+ },39+ {40+ name: "extensionless article markdown image", category: "photos",41+ row: event.Event{Kind: 30023, Content: "![cover](https://cdn.example/photo)"}, want: true,42+ },43+ {44+ name: "fenced URL ignored", category: "podcasts",45+ row: event.Event{Kind: 1, Content: "```\nhttps://cdn.example/episode.mp3\n```"}, want: false,46+ },47+ {48+ name: "inline code URL ignored", category: "podcasts",49+ row: event.Event{Kind: 1, Content: "Use `https://cdn.example/episode.mp3` as an example."}, want: false,50+ },51+ } {52+ t.Run(tc.name+"/"+tc.category, func(t *testing.T) {53+ if got := socialCategoryMatches(tc.row, tc.category); got != tc.want {54+ t.Fatalf("category match = %v, want %v", got, tc.want)55+ }56+ })57+ }58+}
M tinyclient/mobile_header.go +20 -0
M tinyclient/page.html +7 -2
@@ -15,6 +15,7 @@ then feature modules are loaded for the current page. */}}
1515 <meta name="apple-mobile-web-app-status-bar-style" content="default">1616 <meta name="apple-mobile-web-app-title" content="{{.Slug}}">1717 <title>{{.Title}}</title>18+{{if or (and (eq .Tab "social") (eq (socialView .Query).Key "podcasts")) (eq .Tab "social-profile")}}<link rel="alternate" type="application/rss+xml" title="Podcasts" href="{{podcastFeedURL .Query}}">{{end}}1819 <link rel="manifest" href="/manifest.webmanifest">1920 <link rel="icon" href="/icon.svg" type="image/svg+xml">2021 <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -43,6 +44,7 @@ then feature modules are loaded for the current page. */}}
4344 {{else if eq .Tab "search"}}{{template "search" .}}4445 {{else if eq .Tab "social"}}{{template "social" .}}4546 {{else if eq .Tab "social-thread"}}{{template "social-thread" .}}47+{{else if eq .Tab "social-profile"}}{{template "social-profile" .}}4648 {{else if eq .Tab "private"}}{{template "private" .}}4749 {{else if eq .Tab "chat"}}{{template "chat" .}}4850 {{else if eq .Tab "direct"}}{{template "direct" .}}
@@ -111,6 +113,9 @@ then feature modules are loaded for the current page. */}}
111113 <p>Private relay. Sign in with a Nostr signer to continue.</p>112114 {{else if eq $kind "repo"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/repos" title="{{.Query.Get "owner"}}">{{shortID (npub (.Query.Get "owner"))}}</a>/<b>{{.Query.Get "repo"}}</b></span></header>113115 <nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "issues" ""}}"{{if or (eq $v "issues") (eq $v "issue")}} aria-current="page"{{end}}>/issues</a><a href="{{repoURL .Query "prs" ""}}"{{if or (eq $v "prs") (eq $v "pr")}} aria-current="page"{{end}}>/prs</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>116+{{else if eq $kind "social"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/social"><b>social</b></a></span></header>117+<nav aria-label="Social">{{$view := socialView .Query}}{{range socialViews}}<a href="{{.URL}}"{{if and (eq $.Tab "social") (eq $view.Key .Key)}} aria-current="page"{{end}}>/{{.Key}}</a>{{end}}</nav>118+<nav aria-label="Social profile"><a href="/social/profile"{{if eq .Tab "social-profile"}} aria-current="page"{{end}}>/profile</a></nav>114119 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>115120 {{range $group := navGroups manageItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}116121 {{else if eq $kind "rooms"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/chat"><b>chat</b></a></span></header>
@@ -122,7 +127,7 @@ then feature modules are loaded for the current page. */}}
122127 <p><a href="/chat#new-room">+ new room</a></p>123128 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>124129 {{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>account</a></p>{{end}}125-{{end}}<div id="mobile-actions">{{if .Actor}}<a href="/account">Account</a>{{end}}<page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address"><button type="button">Copy page link</button></page-link></div><p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}130+{{end}}<div id="mobile-actions">{{if .Actor}}<a href="/account">Account</a>{{end}}<page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address"><button type="button">Copy page link</button></page-link></div><p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms") (eq $kind "social")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}126131 127132 {{/* The panel holds facts and filters for the current page. */}}128133 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}
@@ -131,7 +136,7 @@ then feature modules are loaded for the current page. */}}
131136 {{else if eq $kind "manage"}}{{template "panel-manage" .}}132137 {{else if or (eq .Tab "chat") (eq .Tab "direct")}}{{template "panel-chat" .}}133138 {{else if eq $kind "rooms"}}{{template "panel-rooms" .}}134-{{else if or (eq .Tab "social") (eq .Tab "social-thread")}}{{template "panel-social" .}}139+{{else if eq $kind "social"}}{{template "panel-social" .}}135140 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}136141 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}137142 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}
A tinyclient/podcast_rss.go +167 -0
@@ -0,0 +1,167 @@
1+package tinyclient2+3+import (4+ "encoding/json"5+ "encoding/xml"6+ "net/http"7+ "net/url"8+ "strconv"9+ "strings"10+ "time"11+)12+13+type podcastRSS struct {14+ XMLName xml.Name `xml:"rss"`15+ Version string `xml:"version,attr"`16+ Atom string `xml:"xmlns:atom,attr"`17+ Channel podcastChannel `xml:"channel"`18+}19+20+type podcastChannel struct {21+ Title string `xml:"title"`22+ Link string `xml:"link"`23+ Description string `xml:"description"`24+ Self podcastAtomLink `xml:"atom:link"`25+ Items []podcastItem `xml:"item"`26+}27+28+type podcastAtomLink struct {29+ Href string `xml:"href,attr"`30+ Rel string `xml:"rel,attr"`31+ Type string `xml:"type,attr"`32+}33+34+type podcastItem struct {35+ Title string `xml:"title"`36+ Link string `xml:"link"`37+ Description string `xml:"description"`38+ GUID podcastGUID `xml:"guid"`39+ PubDate string `xml:"pubDate,omitempty"`40+ Enclosure podcastEnclosure `xml:"enclosure"`41+}42+43+type podcastGUID struct {44+ Value string `xml:",chardata"`45+ IsPermaLink string `xml:"isPermaLink,attr"`46+}47+48+type podcastEnclosure struct {49+ URL string `xml:"url,attr"`50+ Length int64 `xml:"length,attr"`51+ Type string `xml:"type,attr"`52+}53+54+// podcastRSS exposes Social audio posts to conventional podcast readers.55+func (a *App) podcastRSS(w http.ResponseWriter, r *http.Request) {56+ w.Header().Set("Cache-Control", "private, no-store")57+ actor, err := a.resolveActor(r)58+ if err != nil {59+ http.Error(w, err.Error(), socialErrorStatus(err))60+ return61+ }62+ query, err := podcastFeedQuery(r.URL.Query())63+ if err != nil {64+ http.Error(w, err.Error(), http.StatusBadRequest)65+ return66+ }67+ params := socialParameters(query)68+ params["kind"], params["limit"] = "podcasts", 10069+ raw, err := json.Marshal(params)70+ if err != nil {71+ http.Error(w, "invalid social query", http.StatusBadRequest)72+ return73+ }74+ result, err := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{raw}, actor)75+ if err != nil {76+ http.Error(w, err.Error(), socialErrorStatus(err))77+ return78+ }79+ base := strings.TrimSuffix(a.backend.URL(), "/")80+ channel := podcastFeedChannel(base, a.backend.Slug(), query)81+ for _, row := range browseRows(result) {82+ if item, ok := podcastFeedItem(base, valueMap(row)); ok {83+ channel.Items = append(channel.Items, item)84+ }85+ }86+ doc, err := xml.Marshal(podcastRSS{Version: "2.0", Atom: "http://www.w3.org/2005/Atom", Channel: channel})87+ if err != nil {88+ http.Error(w, "could not encode feed", http.StatusInternalServerError)89+ return90+ }91+ w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")92+ w.Header().Set("Content-Length", strconv.Itoa(len(xml.Header)+len(doc)))93+ if r.Method != http.MethodHead {94+ _, _ = w.Write(append([]byte(xml.Header), doc...))95+ }96+}97+98+func podcastFeedQuery(query url.Values) (url.Values, error) {99+ values := url.Values{}100+ if raw := strings.TrimSpace(query.Get("author")); raw != "" {101+ author, err := socialProfileAuthor(raw)102+ if err != nil {103+ return nil, err104+ }105+ values.Set("author", author)106+ }107+ if search := query.Get("q"); search != "" {108+ values.Set("q", search)109+ }110+ return values, nil111+}112+113+func podcastFeedChannel(base, slug string, query url.Values) podcastChannel {114+ channel := podcastChannel{115+ Title: slug + " Podcasts",116+ Link: base + "/social?kind=podcasts",117+ Description: "Audio posts shared on " + slug + ".",118+ Self: podcastAtomLink{Href: base + podcastFeedURL(query), Rel: "self", Type: "application/rss+xml"},119+ }120+ if author := query.Get("author"); author != "" {121+ channel.Title = shortID(author) + " Podcasts | " + slug122+ channel.Link = base + "/social/profile?author=" + url.QueryEscape(author) + "&kind=podcasts"123+ }124+ return channel125+}126+127+func podcastFeedItem(base string, row map[string]any) (podcastItem, bool) {128+ for _, audio := range socialAttachments(row) {129+ if !strings.HasPrefix(strings.ToLower(audio.MIME), "audio/") || socialMediaURL(audio.URL) == "" {130+ continue131+ }132+ itemURL := base + socialURL(row)133+ item := podcastItem{134+ Title: podcastEpisodeTitle(row, audio.Name),135+ Link: itemURL,136+ Description: plainString(row["content"]),137+ GUID: podcastGUID{Value: plainString(row["id"]), IsPermaLink: "false"},138+ Enclosure: podcastEnclosure{URL: audio.URL, Type: audio.MIME, Length: audio.Size},139+ }140+ if item.GUID.Value == "" {141+ item.GUID.Value = itemURL142+ }143+ if address := plainString(row["address"]); address != "" {144+ item.GUID.Value = address145+ }146+ if seconds := unixSeconds(row["created_at"]); seconds > 0 {147+ item.PubDate = time.Unix(seconds, 0).UTC().Format(time.RFC1123Z)148+ }149+ return item, true150+ }151+ return podcastItem{}, false152+}153+154+func podcastEpisodeTitle(row map[string]any, filename string) string {155+ if title := strings.TrimSpace(plainString(row["title"])); title != "" {156+ return title157+ }158+ content := strings.TrimSpace(plainString(row["content"]))159+ line, _, _ := strings.Cut(content, "\n")160+ if line != "" && socialMediaURL(line) == "" {161+ return socialPreview(line)162+ }163+ if filename != "" {164+ return filename165+ }166+ return "Untitled podcast"167+}
A tinyclient/podcast_rss_test.go +138 -0
@@ -0,0 +1,138 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "encoding/xml"7+ "errors"8+ "net/http"9+ "net/http/httptest"10+ "strings"11+ "testing"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/policy"14+)15+16+type podcastRSSBackend struct {17+ fakeBackend18+ result any19+ err error20+ query map[string]any21+ actor string22+ base string23+}24+25+func (b *podcastRSSBackend) URL() string {26+ if b.base != "" {27+ return b.base28+ }29+ return b.fakeBackend.URL()30+}31+32+func (b *podcastRSSBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {33+ b.actor = actor34+ b.query = nil35+ if len(params) > 0 {36+ _ = json.Unmarshal(params[0], &b.query)37+ }38+ return b.result, b.err39+}40+41+func TestPodcastRSSPageBuildsAudioEnclosures(t *testing.T) {42+ author := strings.Repeat("a", 64)43+ b := &podcastRSSBackend{44+ fakeBackend: fakeBackend{policy: policy.Defaults(author)},45+ result: map[string]any{"items": []any{46+ map[string]any{"id": strings.Repeat("b", 64), "kind": 1, "title": `A & <episode>`, "content": "A description https://cdn.example/audio.mp3?a=1&b=2", "created_at": 1700000000, "tags": [][]string{{"imeta", "url https://cdn.example/audio.mp3?a=1&b=2", "m audio/mpeg", "size 42"}, {"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},47+ map[string]any{"id": strings.Repeat("c", 64), "kind": 30023, "content": "no audio", "tags": [][]string{{"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},48+ }},49+ }50+ a := &App{backend: b}51+ r := httptest.NewRequest(http.MethodGet, "/social/podcasts.rss?author="+author, nil)52+ w := httptest.NewRecorder()53+ a.podcastRSS(w, r)54+ if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "application/rss+xml") {55+ t.Fatalf("response: %d %q", w.Code, w.Header().Get("Content-Type"))56+ }57+ var feed podcastRSS58+ if err := xml.Unmarshal(w.Body.Bytes(), &feed); err != nil {59+ t.Fatalf("invalid RSS: %v\n%s", err, w.Body.String())60+ }61+ if len(feed.Channel.Items) != 1 || feed.Channel.Items[0].Enclosure.Length != 42 || feed.Channel.Items[0].Enclosure.Type != "audio/mpeg" {62+ t.Fatalf("items: %#v", feed.Channel.Items)63+ }64+ if !strings.Contains(w.Body.String(), "A &amp; &lt;episode&gt;") || !strings.Contains(w.Body.String(), "a=1&amp;b=2") {65+ t.Fatalf("escaped feed: %s", w.Body.String())66+ }67+ if b.query["kind"] != "podcasts" || b.query["limit"] != float64(100) || b.query["author"] != author {68+ t.Fatalf("query: %#v", b.query)69+ }70+}71+72+func TestPodcastRSSPageUsesUnknownLengthAndPropagatesErrors(t *testing.T) {73+ b := &podcastRSSBackend{74+ fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))},75+ result: map[string]any{76+ "items": []any{map[string]any{77+ "id": "episode", "content": "no body reference",78+ "tags": [][]string{{"imeta", "url https://cdn.example/episode.ogg", "m audio/ogg"}},79+ }},80+ },81+ }82+ a := &App{backend: b}83+ w := httptest.NewRecorder()84+ a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))85+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `length="0"`) {86+ t.Fatalf("unknown length: %d %s", w.Code, w.Body.String())87+ }88+ b.err = errors.New("backend unavailable")89+ w = httptest.NewRecorder()90+ a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))91+ if w.Code != http.StatusForbidden {92+ t.Fatalf("backend error status: %d", w.Code)93+ }94+ a.actor = nil95+ a.actor = func(*http.Request) (string, error) { return "", errors.New("actor failure") }96+ w = httptest.NewRecorder()97+ a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))98+ if w.Code != http.StatusForbidden {99+ t.Fatalf("actor error status: %d", w.Code)100+ }101+}102+103+func TestPodcastRSSRouteNormalizesSubscriptionAndSupportsHead(t *testing.T) {104+ author := strings.Repeat("a", 64)105+ b := &podcastRSSBackend{fakeBackend: fakeBackend{policy: policy.Defaults(author)}, base: "https://relay.example/r/team"}106+ a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return strings.Repeat("b", 64), nil }})107+ if err != nil {108+ t.Fatal(err)109+ }110+ path := "/social/podcasts.rss?author=" + identityNpub(author) + "&q=morning&cursor=old&kind=notes"111+ w := httptest.NewRecorder()112+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))113+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "https://relay.example/r/team/social/podcasts.rss?author="+author+"&amp;q=morning") || !strings.Contains(w.Body.String(), "https://relay.example/r/team/social/profile?author="+author) {114+ t.Fatalf("tenant subscription: %d %s", w.Code, w.Body.String())115+ }116+ if b.query["author"] != author || b.query["cursor"] != nil || b.query["kind"] != "podcasts" || b.actor != strings.Repeat("b", 64) {117+ t.Fatalf("subscription query: %#v actor=%s", b.query, b.actor)118+ }119+ w = httptest.NewRecorder()120+ a.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/social/podcasts.rss", nil))121+ if w.Code != http.StatusOK || w.Body.Len() != 0 || w.Header().Get("Content-Length") == "" || w.Header().Get("Cache-Control") != "private, no-store" || b.query["author"] != nil {122+ t.Fatalf("HEAD response: %d %v query=%v", w.Code, w.Header(), b.query)123+ }124+}125+126+func TestPodcastArticleGUIDSurvivesAnEdit(t *testing.T) {127+ address := "30023:" + strings.Repeat("a", 64) + ":episode"128+ row := map[string]any{"id": "first", "address": address, "kind": 30023, "content": "Episode one", "tags": [][]string{{"imeta", "url https://cdn.example/episode", "m audio/mpeg", "size 100"}}}129+ first, ok := podcastFeedItem("https://relay.example", row)130+ if !ok {131+ t.Fatal("episode omitted")132+ }133+ row["id"] = "second"134+ second, ok := podcastFeedItem("https://relay.example", row)135+ if !ok || first.GUID != second.GUID || second.GUID.Value != address || second.Title != "Episode one" {136+ t.Fatalf("edited episode identity: %#v %#v", first, second)137+ }138+}
M tinyclient/social.html +16 -9
@@ -1,14 +1,18 @@
1-{{define "social"}}<section id="social">2-<header id="social-head"><h1>Social</h1><p>Notes, stories and conversations, newest first.</p></header>1+{{define "social"}}{{$view := socialView .Query}}<section id="social">2+<header id="social-head"><h1>{{$view.Label}}</h1><p>{{$view.Summary}}</p>{{if eq $view.Key "podcasts"}}<p id="podcast-subscribe"><a href="{{podcastFeedURL .Query}}">Subscribe with RSS</a></p>{{end}}</header>33 {{if .Error}}{{template "alert" .Error}}{{else}}44 {{if .Actor}}{{template "socialComposer" .}}{{else}}<p data-social-welcome><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to join the conversation.</p>{{end}}5-<nav id="social-views" aria-label="Social feed"><a href="{{socialQuery .Query "kind" ""}}"{{if eq (.Query.Get "kind") ""}} aria-current="page"{{end}}>All posts</a><a href="{{socialQuery .Query "kind" "notes"}}"{{if eq (.Query.Get "kind") "notes"}} aria-current="page"{{end}}>Notes</a><a href="{{socialQuery .Query "kind" "articles"}}"{{if eq (.Query.Get "kind") "articles"}} aria-current="page"{{end}}>Articles</a></nav>6-{{if .Feed}}<ol id="social-feed">{{range .Feed}}{{template "socialPost" (socialContext . $)}}{{end}}</ol>7-{{else}}<div id="social-empty"><h2>{{if .Query.Get "q"}}No matching posts{{else}}A place to share what’s on your mind{{end}}</h2><p>{{if .Query.Get "q"}}Try another search or clear the filters.{{else}}Post a quick note or take your time with an article. Conversations grow from here.{{end}}</p></div>{{end}}8-{{with index (asMap .Event) "next_cursor"}}<p id="social-pages"><a href="{{socialQuery $.Query "cursor" (str .)}}">Older posts →</a></p>{{end}}5+{{with .Query.Get "author"}}<p id="social-author-filter">Posts by {{template "nostrName" .}} <a href="/social/profile?author={{.}}">View profile →</a></p>{{end}}6+{{template "socialFeed" .}}97 {{end}}<noscript><p>Reading works without JavaScript. Publishing needs JavaScript and a connected signer.</p></noscript>108 </section>{{end}}119 10+{{define "socialFeed"}}11+{{if .Feed}}<ol id="social-feed">{{range .Feed}}{{template "socialPost" (socialContext . $)}}{{end}}</ol>12+{{else}}<div id="social-empty"><h2>{{if .Query.Get "q"}}No matching posts{{else if eq .Tab "social-profile"}}No posts yet{{else if eq (socialView .Query).Key "feed"}}A place to share what’s on your mind{{else}}Nothing here yet{{end}}</h2><p>{{if .Query.Get "q"}}Try another search or clear the filters.{{else if eq .Tab "social-profile"}}Posts from this person will appear here.{{else if eq (socialView .Query).Key "feed"}}Share a note, a story or something you made.{{else}}Posts shared in this category will appear here.{{end}}</p></div>{{end}}13+{{with index (asMap .Event) "next_cursor"}}<p id="social-pages"><a href="{{socialFeedPage $ (str .)}}">Older posts →</a></p>{{end}}14+{{end}}15+1216 {{define "social-thread"}}{{$post := socialContext (index (asMap .Event) "post") .}}<section id="social-thread">1317 <p data-social-back><a href="/social">← Social</a></p>1418 {{if .Error}}{{template "alert" .Error}}{{else}}
@@ -34,7 +38,7 @@
3438 {{template "socialActions" $p}}3539 </li>{{end}}3640 37-{{define "socialByline"}}{{$p := asMap .}}<header data-social-byline><span data-social-avatar><nostr-avatar pubkey="{{index $p "pubkey"}}" aria-hidden="true">{{with socialMediaURL (str (index $p "author_picture"))}}<img src="{{.}}" alt="" loading="lazy" width="36" height="36" referrerpolicy="no-referrer">{{else}}<img src="{{avatarURL (str (index $p "pubkey"))}}" alt="" width="36" height="36" loading="lazy">{{end}}</nostr-avatar></span><span><a href="/social?author={{str (index $p "pubkey")}}"><b>{{if index $p "author_name"}}{{str (index $p "author_name")}}{{else}}{{template "nostrName" (str (index $p "pubkey"))}}{{end}}</b></a> <a href="{{socialURL $p}}"><time datetime="{{datetime (index $p "created_at")}}">{{when (index $p "created_at")}}</time></a>{{if eq (str (index $p "kind")) "30023"}} <small>Article</small>{{end}}{{with index $p "parent_url"}}<small data-social-parent>↳ <a href="{{.}}">Replying{{if index $p "parent_author"}} to {{template "nostrName" (str (index $p "parent_author"))}}{{else}} to a post{{end}}</a></small>{{end}}</span></header>{{end}}41+{{define "socialByline"}}{{$p := asMap .}}<header data-social-byline><span data-social-avatar><nostr-avatar pubkey="{{index $p "pubkey"}}" aria-hidden="true">{{with socialMediaURL (str (index $p "author_picture"))}}<img src="{{.}}" alt="" loading="lazy" width="36" height="36" referrerpolicy="no-referrer">{{else}}<img src="{{avatarURL (str (index $p "pubkey"))}}" alt="" width="36" height="36" loading="lazy">{{end}}</nostr-avatar></span><span><a href="/social/profile?author={{str (index $p "pubkey")}}"><b>{{if index $p "author_name"}}{{str (index $p "author_name")}}{{else}}{{template "nostrName" (str (index $p "pubkey"))}}{{end}}</b></a> <a href="{{socialURL $p}}"><time datetime="{{datetime (index $p "created_at")}}">{{when (index $p "created_at")}}</time></a>{{if eq (str (index $p "kind")) "30023"}} <small>Article</small>{{end}}{{with index $p "parent_url"}}<small data-social-parent>↳ <a href="{{.}}">Replying{{if index $p "parent_author"}} to {{template "nostrName" (str (index $p "parent_author"))}}{{else}} to a post{{end}}</a></small>{{end}}</span></header>{{end}}3842 3943 {{define "socialActions"}}{{$p := asMap .}}<footer data-social-actions>4044 <a href="{{socialURL $p}}#reply">{{if index $p "reply_count"}}{{index $p "reply_count"}} {{if eq (str (index $p "reply_count")) "1"}}reply{{else}}replies{{end}}{{else}}Reply{{end}}</a>
@@ -63,7 +67,10 @@
6367 6468 {{define "socialComment"}}{{$c := asMap .}}<li data-social-comment{{if index $c "nested"}} data-nested-reply{{end}}>{{template "socialByline" $c}}<div data-comment-body>{{socialBody $c}}</div>{{template "socialActions" $c}}</li>{{end}}6569 66-{{define "panel-social"}}{{$stats := asMap (index (asMap .Event) "stats")}}<section><h4>{{if eq .Tab "social"}}In this feed{{else}}Conversation{{end}}</h4>{{if eq .Tab "social"}}<table><tr><th>notes</th><td>{{or (index $stats "notes") 0}}</td></tr><tr><th>articles</th><td>{{or (index $stats "articles") 0}}</td></tr><tr><th>replies</th><td>{{or (index $stats "comments") 0}}</td></tr></table><p><small>Newest first. Replies and reactions stay with their posts.</small></p>{{else}}<p>Follow the discussion or add a reply of your own.</p><p><a href="/social">All posts</a></p>{{end}}</section>67-{{if eq .Tab "social"}}<section><h4>Find a post</h4><form action="/social" method="get"><input type="hidden" name="kind" value="{{.Query.Get "kind"}}"><label>Search <input name="q" type="search" value="{{.Query.Get "q"}}" placeholder="Words or a title"></label><label>Author <input name="author" value="{{.Query.Get "author"}}" placeholder="Public key"></label><button>Search</button> <a href="/social">Clear</a></form></section>{{end}}70+{{define "panel-social"}}71+{{if eq .Tab "social-thread"}}<section><h4>Conversation</h4><p>Follow the discussion or add a reply of your own.</p><p><a href="/social">Back to feed</a></p></section>72+{{else if eq .Tab "social-profile"}}<section><h4>Profile</h4>{{if .View}}<p>This person’s posts, newest first.</p><p><a href="{{podcastFeedURL .Query}}">Subscribe to podcasts</a></p>{{else}}<p>Open your profile or look up another person.</p>{{end}}{{if .Actor}}<p><a href="/social/profile">Your profile</a></p>{{end}}<p><a href="/social">Browse the feed</a></p></section>73+{{else}}{{$view := socialView .Query}}<section><h4>{{$view.Label}}</h4><p>{{$view.Summary}}</p><p>Newest first. Replies and reactions stay with their posts.</p></section>74+<section><h4>Find a post</h4><form action="/social" method="get"><input type="hidden" name="kind" value="{{$view.Key}}"><label>Search <input name="q" type="search" value="{{.Query.Get "q"}}" placeholder="Words or a title"></label><label>Author <input name="author" value="{{.Query.Get "author"}}" placeholder="Public key"></label><button>Search</button> <a href="{{$view.URL}}">Clear</a></form></section>{{end}}6875 <section><h4>Follow the feed</h4><p><a href="/social.xml">Atom</a> | <a href="/social.json">JSON Feed</a></p></section>6976 {{if not .Actor}}<section><h4>Join in</h4><p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to post, reply and react.</p></section>{{end}}{{end}}
M tinyclient/social_content.go +21 -6
@@ -36,6 +36,12 @@ func socialBodyWith(value any, blocks blockRenderer) template.HTML {
3636 kind := plainString(m["kind"])3737 breaks = kind != "30023"3838 }39+ var supplemental []roomAttachment40+ for _, item := range attachments {41+ if !strings.Contains(content, item.URL) {42+ supplemental = append(supplemental, item)43+ }44+ }3945 nonce := rand.Text()4046 tokenFor := func(raw string) string { return nonce + socialMediaToken(raw) }4147 for _, item := range attachments {
@@ -61,6 +67,9 @@ func socialBodyWith(value any, blocks blockRenderer) template.HTML {
6167 rendered = strings.ReplaceAll(rendered, "<p>"+token+"</p>", socialMediaFigure(item))6268 rendered = strings.ReplaceAll(rendered, token, socialMediaInline(item))6369 }70+ for _, item := range supplemental {71+ rendered += socialMediaFigure(item)72+ }6473 return template.HTML(socialNostrLinks(socialMediaDimensions(rendered, value)))6574 }6675
@@ -244,15 +253,21 @@ func socialMediaInline(item roomAttachment) string {
244253 // Media metadata is preferred, while familiar file extensions keep older245254 // clients' ordinary image and video links useful without a server fetch.246255 func socialAttachments(row map[string]any) []roomAttachment {247- items := roomAttachments(row)256+ content := plainString(row["content"])257+ items := roomAttachmentsFromTags(roomTags(row))258+ filtered := items[:0]248259 seen := map[string]bool{}249- for i := range items {250- if !strings.Contains(items[i].MIME, "/") {251- items[i].MIME = socialMIME(items[i].URL, items[i].MIME)260+ for _, item := range items {261+ if seen[item.URL] || strings.Contains(content, item.URL) && !roomAttachmentURLVisible(content, item.URL) {262+ continue252263 }253- seen[items[i].URL] = true264+ if !strings.Contains(item.MIME, "/") {265+ item.MIME = socialMIME(item.URL, item.MIME)266+ }267+ seen[item.URL] = true268+ filtered = append(filtered, item)254269 }255- content := plainString(row["content"])270+ items = filtered256271 replaceSocialOutsideCode(content, func(text string) string {257272 for _, match := range roomAttachmentReference.FindAllStringSubmatch(text, -1) {258273 raw := attachmentReferenceURL(match)
M tinyclient/social_content_test.go +11 -0
M tinyclient/social_mentions.go +1 -1
M tinyclient/social_mentions_test.go +1 -1
A tinyclient/social_navigation.go +68 -0
@@ -0,0 +1,68 @@
1+package tinyclient2+3+import "net/url"4+5+type socialViewItem struct {6+ Key, Label, Summary, URL string7+}8+9+func socialViews() []socialViewItem {10+ return []socialViewItem{11+ {"feed", "Feed", "Notes, posts and media, together in one place.", "/social"},12+ {"notes", "Notes", "Quick thoughts and everyday updates.", "/social?kind=notes"},13+ {"posts", "Posts", "Stories, essays and longer reads.", "/social?kind=posts"},14+ {"photos", "Photos", "Pictures and the stories behind them.", "/social?kind=photos"},15+ {"videos", "Videos", "Clips and films shared in posts.", "/social?kind=videos"},16+ {"podcasts", "Podcasts", "Episodes and audio shared in posts.", "/social?kind=podcasts"},17+ }18+}19+20+func socialView(query url.Values) socialViewItem {21+ kind := query.Get("kind")22+ if kind == "" {23+ switch query.Get("kinds") {24+ case "1":25+ kind = "notes"26+ case "30023":27+ kind = "posts"28+ }29+ }30+ if kind == "articles" {31+ kind = "posts"32+ }33+ for _, view := range socialViews() {34+ if view.Key == kind {35+ return view36+ }37+ }38+ return socialViews()[0]39+}40+41+func socialFeedPage(data PageData, cursor string) string {42+ path := "/social"43+ if data.Tab == "social-profile" {44+ path = "/social/profile"45+ }46+ query := url.Values{}47+ for _, key := range []string{"kind", "kinds", "author", "q"} {48+ if value := data.Query.Get(key); value != "" {49+ query.Set(key, value)50+ }51+ }52+ query.Set("cursor", cursor)53+ return path + "?" + query.Encode()54+}55+56+func podcastFeedURL(query url.Values) string {57+ values := url.Values{}58+ for _, key := range []string{"author", "q"} {59+ if value := query.Get(key); value != "" {60+ values.Set(key, value)61+ }62+ }63+ path := "/social/podcasts.rss"64+ if len(values) > 0 {65+ path += "?" + values.Encode()66+ }67+ return path68+}
A tinyclient/social_navigation_test.go +50 -0
@@ -0,0 +1,50 @@
1+package tinyclient2+3+import (4+ "net/http"5+ "net/http/httptest"6+ "net/url"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/policy"11+)12+13+func TestSocialNavigationSharesOneRail(t *testing.T) {14+ for _, kind := range []string{"feed", "notes", "posts", "photos", "videos", "podcasts"} {15+ t.Run(kind, func(t *testing.T) {16+ b := &socialBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}17+ a, err := New(b, Options{})18+ if err != nil {19+ t.Fatal(err)20+ }21+ r := httptest.NewRecorder()22+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/social?kind="+kind, nil))23+ body := r.Body.String()24+ for _, marker := range []string{`aria-label="Social"`, ">/feed</a>", ">/notes</a>", ">/posts</a>", ">/photos</a>", ">/videos</a>", ">/podcasts</a>", `href="/social/profile"`, `href="/social/profile?author=`} {25+ if !strings.Contains(body, marker) {26+ t.Errorf("missing %q", marker)27+ }28+ }29+ if r.Code != http.StatusOK || strings.Contains(body, "class=") || strings.Contains(body, "\u00b7") {30+ t.Fatalf("invalid social page: status=%d", r.Code)31+ }32+ if !strings.Contains(body, `aria-current="page">/`+kind+`</a>`) {33+ t.Fatal("selected social view is missing")34+ }35+ })36+ }37+}38+39+func TestSocialViewAliasesAndMobileTitle(t *testing.T) {40+ for _, query := range []url.Values{{"kind": {"articles"}}, {"kinds": {"30023"}}} {41+ view := socialView(query)42+ if view.Key != "posts" || view.Label != "Posts" {43+ t.Fatalf("article alias = %+v", view)44+ }45+ }46+ header := mobileHeader(PageData{Tab: "social", Path: "/social", Query: url.Values{"kind": {"photos"}}, Slug: "tiny"})47+ if header.Title != "Photos" || header.Subtitle != "Social" || header.BackURL != "/social" {48+ t.Fatalf("category mobile header = %+v", header)49+ }50+}
M tinyclient/social_pages.go +23 -3
@@ -37,6 +37,14 @@ func (a *App) handleSocialRoute(w http.ResponseWriter, r *http.Request) bool {
3737 a.socialSyndication(w, r)3838 return true3939 }40+ if p == "/social/profile" {41+ a.socialProfilePage(w, r)42+ return true43+ }44+ if p == "/social/podcasts.rss" {45+ a.podcastRSS(w, r)46+ return true47+ }4048 a.socialPage(w, r)4149 return true4250 }
@@ -78,12 +86,11 @@ func (a *App) socialPage(w http.ResponseWriter, r *http.Request) {
7886 data := PageData{Tab: tab, Title: "Social | " + a.backend.Slug(), Event: result, Query: r.URL.Query()}7987 if tab == "social" {8088 data.Feed = browseRows(result)89+ data.Title = socialView(data.Query).Label + " | Social | " + a.backend.Slug()8190 }8291 if err != nil {8392 data.Error = err.Error()84- if strings.HasPrefix(err.Error(), "not found:") {85- w.WriteHeader(http.StatusNotFound)86- }93+ w.WriteHeader(socialErrorStatus(err))8794 }8895 a.render(w, r, data)8996 }
@@ -221,3 +228,16 @@ func (a *App) socialSyndication(w http.ResponseWriter, r *http.Request) {
221228 }222229 writeJSON(w, http.StatusOK, map[string]any{"version": "https://jsonfeed.org/version/1.1", "title": a.backend.Slug() + " Social", "home_page_url": strings.TrimSuffix(a.backend.URL(), "/") + "/social", "feed_url": strings.TrimSuffix(a.backend.URL(), "/") + "/social.json", "items": items})223230 }231+232+func socialErrorStatus(err error) int {233+ switch {234+ case strings.HasPrefix(err.Error(), "invalid:"):235+ return http.StatusBadRequest236+ case strings.HasPrefix(err.Error(), "not found:"):237+ return http.StatusNotFound238+ case strings.HasPrefix(err.Error(), "auth-required:"):239+ return http.StatusUnauthorized240+ default:241+ return publicErrorStatus(err)242+ }243+}
M tinyclient/social_pages_test.go +1 -1
A tinyclient/social_prefix_test.go +59 -0
@@ -0,0 +1,59 @@
1+package tinyclient2+3+import (4+ "net/http"5+ "net/http/httptest"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/policy"10+)11+12+func prefixedSocialRequest(path, query string) *http.Request {13+ r := httptest.NewRequest(http.MethodGet, path+"?"+query, nil)14+ r.RequestURI = "/r/team" + path + "?" + query15+ return r16+}17+18+func TestSocialLinksKeepTenantPrefix(t *testing.T) {19+ a, err := New(&socialBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}, Options{})20+ if err != nil {21+ t.Fatal(err)22+ }23+ w := httptest.NewRecorder()24+ a.ServeHTTP(w, prefixedSocialRequest("/social", "kind=podcasts&author="+strings.Repeat("a", 64)))25+ body := w.Body.String()26+ for _, want := range []string{27+ `href="/r/team/social"`,28+ `href="/r/team/social?kind=photos"`,29+ `href="/r/team/social/profile"`,30+ `href="/r/team/social/profile?author=`,31+ `href="/r/team/social/podcasts.rss?author=`,32+ `href="/r/team/social?author=`,33+ } {34+ if !strings.Contains(body, want) {35+ t.Errorf("missing tenant-prefixed link %q in %s", want, body)36+ }37+ }38+ if strings.Contains(body, `href="/social`) {39+ t.Fatalf("unscoped Social link in %s", body)40+ }41+}42+43+func TestSocialProfilePaginationKeepsTenantPrefix(t *testing.T) {44+ owner, other := strings.Repeat("a", 64), strings.Repeat("b", 64)45+ b := &socialProfileTestBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}46+ a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})47+ if err != nil {48+ t.Fatal(err)49+ }50+ w := httptest.NewRecorder()51+ a.ServeHTTP(w, prefixedSocialRequest("/social/profile", "author="+other+"&kind=photos"))52+ body := w.Body.String()53+ if w.Code != http.StatusOK || !strings.Contains(body, `/r/team/social/profile?author=`+other+`&amp;cursor=next&amp;kind=photos`) {54+ t.Fatalf("profile pagination lost tenant prefix: %d %s", w.Code, body)55+ }56+ if strings.Contains(body, `href="/social`) {57+ t.Fatalf("unscoped profile link in %s", body)58+ }59+}
A tinyclient/social_profile.go +90 -0
@@ -0,0 +1,90 @@
1+package tinyclient2+3+import (4+ "encoding/hex"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "strings"9+)10+11+// socialProfileAuthor accepts the relay's canonical hex key and the usual12+// Nostr npub spelling, returning the canonical key used by backend queries.13+func socialProfileAuthor(raw string) (string, error) {14+ raw = strings.TrimSpace(raw)15+ if strings.HasPrefix(strings.ToLower(raw), "npub1") {16+ hrp, data, ok := bech32Decode(strings.ToLower(raw))17+ if !ok || hrp != "npub" || len(data) != 32 {18+ return "", errors.New("invalid npub")19+ }20+ return hex.EncodeToString(data), nil21+ }22+ if len(raw) != 64 {23+ return "", errors.New("author must be a 64-character public key or npub")24+ }25+ if _, err := hex.DecodeString(raw); err != nil {26+ return "", errors.New("author must be a 64-character public key or npub")27+ }28+ return strings.ToLower(raw), nil29+}30+31+func (a *App) socialProfilePage(w http.ResponseWriter, r *http.Request) {32+ actor, actorErr := a.resolveActor(r)33+ query := r.URL.Query()34+ data := PageData{Tab: "social-profile", Title: "Profile | " + a.backend.Slug(), Query: query}35+ if actorErr != nil {36+ data.Error = actorErr.Error()37+ w.WriteHeader(socialErrorStatus(actorErr))38+ a.render(w, r, data)39+ return40+ }41+ author := strings.TrimSpace(query.Get("author"))42+ if author == "" {43+ author = actor44+ }45+ if author == "" {46+ a.render(w, r, data)47+ return48+ }49+ canonical, err := socialProfileAuthor(author)50+ if err != nil {51+ data.Error = err.Error()52+ w.WriteHeader(http.StatusBadRequest)53+ a.render(w, r, data)54+ return55+ }56+ data.View = canonical57+ data.Query.Set("author", canonical)58+59+ profileRaw, err := json.Marshal(map[string]any{"pubkey": canonical})60+ if err != nil {61+ http.Error(w, "invalid profile query", http.StatusBadRequest)62+ return63+ }64+ profile, profileErr := a.backend.Query(r.Context(), "browseprofile", []json.RawMessage{profileRaw}, actor)65+ if profileErr != nil {66+ data.Error = profileErr.Error()67+ w.WriteHeader(socialErrorStatus(profileErr))68+ a.render(w, r, data)69+ return70+ }71+ feedParams := socialParameters(data.Query)72+ feedParams["author"] = canonical73+ feedRaw, marshalErr := json.Marshal(feedParams)74+ if marshalErr != nil {75+ http.Error(w, "invalid social query", http.StatusBadRequest)76+ return77+ }78+ feed, feedErr := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{feedRaw}, actor)79+ if feedErr != nil {80+ data.Error = feedErr.Error()81+ w.WriteHeader(socialErrorStatus(feedErr))82+ }83+ data.Event = map[string]any{84+ "profile": valueMap(profile)["profile"],85+ "pubkey": canonical,86+ "next_cursor": valueMap(feed)["next_cursor"],87+ }88+ data.Feed = browseRows(feed)89+ a.render(w, r, data)90+}
A tinyclient/social_profile.html +19 -0
@@ -0,0 +1,19 @@
1+{{define "social-profile"}}<section id="social-profile">2+{{if .Error}}<p role="alert">{{.Error}}</p>3+{{else if not .View}}<header><h1>Social profile</h1><p>See someone’s profile and their posts in one place.</p></header>4+<form method="get" action="/social/profile"><label for="profile-author">Public key</label><input id="profile-author" name="author" placeholder="64 hex characters or npub" required><button>View profile</button></form>5+{{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to open your own profile.</p>{{end}}6+{{else}}{{$profile := asMap (index (asMap .Event) "profile")}}<header id="social-profile-head">7+{{with socialMediaURL (str (index $profile "banner"))}}<img id="social-profile-banner" src="{{.}}" alt="" loading="lazy" referrerpolicy="no-referrer">{{end}}8+<img id="social-profile-picture" src="{{with socialMediaURL (str (index $profile "picture"))}}{{.}}{{else}}{{avatarURL .View}}{{end}}" alt="" width="80" height="80" loading="lazy" referrerpolicy="no-referrer">9+<h1>{{or (str (index $profile "display_name")) (str (index $profile "name")) (shortID .View)}}</h1><p>{{template "nostrKey" .View}}</p>10+{{with str (index $profile "about")}}<p id="social-profile-about">{{.}}</p>{{end}}11+{{with socialMediaURL (str (index $profile "website"))}}<p><a href="{{.}}" rel="noopener noreferrer">{{.}}</a></p>{{end}}12+{{with str (index $profile "nip05")}}<p><small>NIP-05 {{.}}</small></p>{{end}}13+{{if eq .Actor .View}}<p><a href="/profile">Edit profile</a></p>{{end}}14+</header>15+<nav id="social-profile-views" aria-label="Profile posts">{{range socialViews}}{{$kind := .Key}}<a href="/social/profile?author={{urlquery $.View}}{{if ne $kind "feed"}}&amp;kind={{urlquery $kind}}{{end}}"{{if eq (socialView $.Query).Key $kind}} aria-current="page"{{end}}>{{if eq $kind "feed"}}All posts{{else}}{{.Label}}{{end}}</a>{{end}}</nav>16+{{if eq (socialView .Query).Key "podcasts"}}<p id="social-profile-subscribe"><a href="{{podcastFeedURL .Query}}">Subscribe with RSS</a></p>{{end}}17+{{template "socialFeed" .}}18+<form method="get" action="/social/profile"><label for="profile-author-again">View another profile</label><input id="profile-author-again" name="author" value="{{.View}}"><button>View</button></form>19+{{end}}</section>{{end}}
A tinyclient/social_profile_test.go +129 -0
@@ -0,0 +1,129 @@
1+package tinyclient2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+func TestSocialProfileAuthorAcceptsHexAndNpub(t *testing.T) {16+ hexKey := strings.Repeat("ab", 32)17+ if got, err := socialProfileAuthor(hexKey); err != nil || got != hexKey {18+ t.Fatalf("hex author = %q, %v", got, err)19+ }20+ npub := "npub1424242424242424242424242424242424242424242424242424qamrcaj"21+ if got, err := socialProfileAuthor(npub); err != nil || got != strings.Repeat("a", 64) {22+ t.Fatalf("npub author = %q, %v", got, err)23+ }24+}25+26+func TestSocialProfileAuthorRejectsInvalidKeys(t *testing.T) {27+ for _, value := range []string{"", "alice", strings.Repeat("g", 64), "npub1invalid"} {28+ if got, err := socialProfileAuthor(value); err == nil || got != "" {29+ t.Errorf("author %q accepted as %q", value, got)30+ }31+ }32+}33+34+type socialProfileTestBackend struct {35+ fakeBackend36+ queries []map[string]any37+ failMethod string38+}39+40+func (b *socialProfileTestBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) {41+ var query map[string]any42+ _ = json.Unmarshal(params[0], &query)43+ b.queries = append(b.queries, query)44+ if method == b.failMethod {45+ return nil, errors.New("restricted: profile access denied")46+ }47+ switch method {48+ case "browseprofile":49+ return map[string]any{"profile": map[string]any{"name": "River", "website": "https://river.example"}}, nil50+ case "browsesocial":51+ return map[string]any{"items": []any{map[string]any{"id": "post", "kind": 1, "pubkey": query["author"], "content": "From River"}}, "next_cursor": "next"}, nil52+ default:53+ return nil, nil54+ }55+}56+57+func TestSocialProfilePageUsesActorAndAuthorScopedFeed(t *testing.T) {58+ owner := strings.Repeat("a", 64)59+ b := &socialProfileTestBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}60+ a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})61+ if err != nil {62+ t.Fatal(err)63+ }64+ w := httptest.NewRecorder()65+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/social/profile?kind=notes", nil))66+ body := w.Body.String()67+ if w.Code != http.StatusOK || !strings.Contains(body, "River") || !strings.Contains(body, "From River") || !strings.Contains(body, "Older posts") || !strings.Contains(body, "/profile") {68+ t.Fatalf("own profile: %d %s", w.Code, body)69+ }70+ if len(b.queries) != 2 || b.queries[0]["pubkey"] != owner || b.queries[1]["author"] != owner || b.queries[1]["kind"] != "notes" {71+ t.Fatalf("profile queries: %#v", b.queries)72+ }73+}74+75+func TestSocialProfilePageGuestCanViewOtherAndMustChooseOwn(t *testing.T) {76+ other := strings.Repeat("b", 64)77+ b := &socialProfileTestBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}78+ a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return "", nil }})79+ if err != nil {80+ t.Fatal(err)81+ }82+ w := httptest.NewRecorder()83+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/social/profile?author="+other, nil))84+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "From River") || strings.Contains(w.Body.String(), "Edit profile") {85+ t.Fatalf("guest profile: %d %s", w.Code, w.Body.String())86+ }87+ w = httptest.NewRecorder()88+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/social/profile", nil))89+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Sign in") || !strings.Contains(w.Body.String(), "View profile") {90+ t.Fatalf("guest profile chooser: %d %s", w.Code, w.Body.String())91+ }92+}93+94+func TestSocialProfileErrorsDoNotRenderProfileOrFeed(t *testing.T) {95+ for _, method := range []string{"browseprofile", "browsesocial"} {96+ t.Run(method, func(t *testing.T) {97+ key := strings.Repeat("a", 64)98+ b := &socialProfileTestBackend{fakeBackend: fakeBackend{policy: policy.Defaults(key)}, failMethod: method}99+ a, err := New(b, Options{})100+ if err != nil {101+ t.Fatal(err)102+ }103+ w := httptest.NewRecorder()104+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/social/profile?author="+key, nil))105+ body := w.Body.String()106+ if w.Code != http.StatusForbidden || !strings.Contains(body, "profile access denied") || strings.Contains(body, `id="social-profile-head"`) || strings.Contains(body, "No posts yet") {107+ t.Fatalf("failed profile response: %d %s", w.Code, body)108+ }109+ })110+ }111+}112+113+func TestSocialProfileOtherAuthorKeepsPaginationAndHidesEditing(t *testing.T) {114+ owner, other := strings.Repeat("a", 64), strings.Repeat("b", 64)115+ b := &socialProfileTestBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}116+ a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})117+ if err != nil {118+ t.Fatal(err)119+ }120+ w := httptest.NewRecorder()121+ a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/social/profile?author="+other+"&kind=photos&q=river&cursor=old", nil))122+ body := w.Body.String()123+ if w.Code != http.StatusOK || strings.Contains(body, "Edit profile") || !strings.Contains(body, `/social/profile?author=`+other+`&amp;cursor=next&amp;kind=photos&amp;q=river`) {124+ t.Fatalf("other profile pagination: %d %s", w.Code, body)125+ }126+ if b.queries[1]["author"] != other || b.queries[1]["cursor"] != "old" || b.queries[1]["kind"] != "photos" {127+ t.Fatalf("author-scoped page: %#v", b.queries)128+ }129+}
M tinyclient/style.css +21 -2
@@ -454,7 +454,7 @@ room-compose{bottom:0;margin:8px -2.5ch 0;padding:8px 12px calc(8px + env(safe-a
454454 455455 /* Social: a calm reading stream that keeps short notes quick and gives456456 long-form posts a clear title, image and reading measure. */457-#social,#social-thread{max-width:760px}457+#social,#social-thread,#social-profile{max-width:760px}458458 #social-head{border-bottom:1px solid var(--line);padding-bottom:10px;margin-bottom:0}459459 #social-head h1{margin-bottom:4px}#social-head p{color:var(--muted);margin:0;max-width:60ch}460460 #social-views{border-bottom:1px solid var(--line);margin:0 0 4px;padding:0}
@@ -483,7 +483,7 @@ room-compose{bottom:0;margin:8px -2.5ch 0;padding:8px 12px calc(8px + env(safe-a
483483 social-compose{display:block;border-bottom:1px solid var(--line);padding:12px 0}social-compose form{max-width:720px}social-compose label{display:block;color:var(--muted);font:13px var(--sans)}social-compose textarea{display:block;margin:5px 0 8px;font:15px/1.5 var(--sans);min-height:5em;resize:vertical}social-compose select{margin:0 8px 0 4px;font:12px var(--mono)}social-compose output{display:block;color:var(--muted);font:12px var(--mono);margin-top:5px}social-reaction{display:inline-block;margin-right:8px}484484 #social-thread-post{list-style:none;margin:0;padding:0}#social-thread-post> [data-social-post]{display:block;border-bottom:0;max-width:720px;padding-bottom:12px}#social-thread-post> [data-social-post]>[data-post-body=article]{font-size:17px;line-height:1.75}#social-comments{border-top:1px solid var(--line);margin-top:22px;padding-top:8px;max-width:720px}#social-comments h2{margin:0 0 8px;font-size:17px}#social-comments ol{list-style:none;margin:0;padding:0}#social-comments li{border-bottom:1px solid var(--line);padding:14px 0}#social-comments [data-comment-body]{font:14px/1.55 var(--sans);overflow-wrap:anywhere}#social-comments [data-comment-body] p{margin:0 0 8px}#social-comments [data-comment-body]>:last-child{margin-bottom:0}485485 @media(max-width:720px){#social-feed>li{padding:14px 0 16px}[data-post-body=article]{font-size:15px;line-height:1.6}[data-post-media] img{max-height:260px}#social-views a{padding-right:11px}#social-comments{margin-top:16px}}486-#social,#social-thread{margin:0 auto;width:100%;box-sizing:border-box}486+#social,#social-thread,#social-profile{margin:0 auto;width:100%;box-sizing:border-box}487487 #social-head{padding-bottom:18px}#social-head h1{font-size:28px;letter-spacing:-.6px}488488 #social-write{padding:14px 0 0;font-size:13px}#social-write a{display:inline-block;padding:6px 12px;border-radius:16px;color:var(--muted)}#social-write a[aria-current=page]{color:var(--accent);background:var(--soft)}489489 #social-feed>li{padding:23px 0}#social-feed>li[data-kind="30023"] h2{font:600 26px/1.22 Georgia,serif;letter-spacing:-.25px}#social-feed>li[data-kind="30023"] h2 a{color:var(--ink)}
@@ -504,6 +504,25 @@ social-compose input:not([type=file]),social-compose textarea{width:100%;box-siz
504504 [data-reaction-emoji]{display:inline-block;width:20px;height:20px;object-fit:contain;vertical-align:middle}505505 [data-post-body=note]>img,[data-comment-body]>img{display:block;width:auto;max-height:560px;margin:12px 0}506506 507+#social-author-filter{font-size:13px;color:var(--muted);padding:14px 0;border-bottom:1px solid var(--line)}508+#social-author-filter a{margin-left:10px}509+#social-head #podcast-subscribe{margin-top:12px;font-size:13px}510+#social-profile-head{padding-bottom:20px;border-bottom:1px solid var(--line);overflow-wrap:anywhere}511+#social-profile-head h1{font-size:28px;letter-spacing:-.5px;margin:14px 0 6px}512+#social-profile-head>p{max-width:60ch}513+#social-profile-banner{display:block;width:100%;height:180px;object-fit:cover;border-radius:8px;margin-bottom:18px}514+#social-profile-picture{display:block;width:80px;height:80px;object-fit:cover;border-radius:50%;background:var(--soft)}515+#social-profile-about{white-space:pre-wrap;margin:16px 0;line-height:1.6}516+#social-profile-views{border-bottom:1px solid var(--line);margin:0 0 4px}517+#social-profile-views a{display:inline-block;margin-right:18px;padding:12px 0 10px;color:var(--muted);border-bottom:2px solid transparent;font-size:13px}518+#social-profile-views a:last-child{margin-right:0}519+#social-profile-views a[aria-current=page]{color:var(--accent);border-color:var(--accent)}520+#social-profile>form{margin:22px 0}521+#social-profile>form label{display:block;margin-bottom:8px;color:var(--muted);font-size:13px}522+#social-profile>form input{width:calc(100% - 126px);min-width:0}523+#social-profile>form button{padding:7px 14px;margin-left:8px}524+@media(max-width:720px){#social-profile-head h1{font-size:25px}#social-profile-banner{height:130px}#social-profile-views a{font-size:12px;margin-right:15px}}525+507526 /* Direct conversations share the room palette and keep their plaintext in the browser. */508527 #chat,#direct-chat{max-width:860px;margin:0 auto;box-sizing:border-box}509528 #chat>p,#direct-chat>header>p{color:var(--muted)}
M tinyclient/templates.go +8 -1