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>
| M | docs/ | 20 | +++- |
| M | internal/ | 7 | +- |
| M | internal/ | 59 | +++++++++++ |
| A | internal/ | 167 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 58 | ++++++++++ |
| M | tinyclient/ | 20 | ++++ |
| M | tinyclient/ | 9 | +- |
| A | tinyclient/ | 167 | ++++++++++++++++++++++++++++++ |
| A | tinyclient/ | 138 | +++++++++++++++++++++++++ |
| M | tinyclient/ | 25 | +++-- |
| M | tinyclient/ | 27 | ++++- |
| M | tinyclient/ | 11 | ++ |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 2 | +- |
| A | tinyclient/ | 68 | ++++++++++++ |
| A | tinyclient/ | 50 | +++++++++ |
| M | tinyclient/ | 26 | ++++- |
| M | tinyclient/ | 2 | +- |
| A | tinyclient/ | 59 | +++++++++++ |
| A | tinyclient/ | 90 | ++++++++++++++++ |
| A | tinyclient/ | 19 | +++ |
| A | tinyclient/ | 129 | +++++++++++++++++++++++ |
| M | tinyclient/ | 23 | ++++- |
| M | tinyclient/ | 9 | +- |
@@ -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 @@ -110,11 +110,12 @@ func socialOlder(a, b event.Event) bool {
110110 func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {111111 kinds := []int{1, 30023}112112 switch q.Kind {113- case "", "all":113+ case "", "all", "feed":114114 case "notes":115115 kinds = []int{1}116- case "articles":116+ case "articles", "posts":117117 kinds = []int{30023}118+ case "photos", "videos", "podcasts":118119 default:119120 return nil, errors.New("invalid: social kind")120121 }@@ -136,7 +137,7 @@ func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseR
136137 }137138 for _, row := range page.Events {138139 cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}139- if socialFeedRoot(row) && t.gate.CanSee(ctx, row, session, &filter) && socialMatches(row, q.Query) {140+ if socialFeedRoot(row) && t.gate.CanSee(ctx, row, session, &filter) && socialCategoryMatches(row, q.Kind) && socialMatches(row, q.Query) {140141 rows = append(rows, row)141142 }142143 if len(rows) > q.Limit {@@ -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@@ -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+}@@ -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: ""}, 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+}@@ -89,6 +89,26 @@ func roomHeader(data PageData, route roomPath, thread bool) mobileHeaderData {
8989 func generalMobileHeader(data PageData) mobileHeaderData {9090 path := data.Path9191 result := mobileHeaderData{Title: mobileTitle(path, data), Subtitle: data.Slug, BackLabel: "Back"}92+ if data.Tab == "social" {93+ view := socialView(data.Query)94+ result.Title, result.Subtitle = view.Label, "Social"95+ if view.Key != "feed" {96+ result.BackURL, result.BackLabel = "/social", "Back to feed"97+ return result98+ }99+ }100+ if data.Tab == "social-profile" {101+ result.Title, result.Subtitle = "Profile", "Social"102+ if data.Error == "" {103+ profile := valueMap(valueMap(data.Event)["profile"])104+ for _, field := range []string{"display_name", "name"} {105+ if name := plainString(profile[field]); name != "" {106+ result.Title = shortHeaderText(name)107+ break108+ }109+ }110+ }111+ }92112 if data.Tab == "social-thread" {93113 result.Title = eventShortTitle(data.Event, "Social")94114 result.Subtitle = "Social"@@ -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">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">← 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">← all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms") (eq $kind "social")}}<a href="/">← 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" .}}@@ -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+}@@ -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 & <episode>") || !strings.Contains(w.Body.String(), "a=1&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+"&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+}@@ -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}}@@ -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)@@ -44,6 +44,17 @@ func TestSocialNoteIsPlaintext(t *testing.T) {
4444 }4545 }4646 47+func TestSocialBodyRendersMetadataOnlyMediaOnce(t *testing.T) {48+ row := map[string]any{"kind": 1, "content": "This week's conversation.", "tags": [][]string{49+ {"imeta", "url https://cdn.test/episode", "m audio/mpeg"},50+ {"imeta", "url https://cdn.test/episode", "m audio/mpeg"},51+ }}52+ got := string(socialBody(row))53+ if strings.Count(got, "<audio controls") != 1 || !strings.Contains(got, `src="https://cdn.test/episode"`) {54+ t.Fatalf("metadata audio missing or repeated: %s", got)55+ }56+}57+4758 func TestSocialMediaSkipsInlineAndFencedCode(t *testing.T) {4859 url := "https://cdn.example.test/hidden.png"4960 row := map[string]any{"kind": "30023", "content": "``\n\n````\n\n````", "tags": [][]string{{"imeta", "url " + url, "m image/png"}}}@@ -49,7 +49,7 @@ func socialNostrDestination(target string) (string, string, bool) {
4949 return "", "", false5050 }5151 pubkey := hex.EncodeToString(fields)52- return "/social?author=" + pubkey, `<nostr-name pubkey="` + pubkey + `">` + shortID(pubkey) + `</nostr-name>`, true52+ return "/social/profile?author=" + pubkey, `<nostr-name pubkey="` + pubkey + `">` + shortID(pubkey) + `</nostr-name>`, true5353 case "note", "nevent":5454 id := data5555 if hrp == "nevent" {@@ -22,7 +22,7 @@ func TestSocialNostrLinksRewritesKnownReferences(t *testing.T) {
2222 ref string2323 want string2424 }{25- {"profile", "nostr:" + bech32("npub", pubkeyBytes), `<a href="/social?author=` + pubkey + `"><nostr-name pubkey="` + pubkey + `">` + shortID(pubkey) + `</nostr-name></a>`},25+ {"profile", "nostr:" + bech32("npub", pubkeyBytes), `<a href="/social/profile?author=` + pubkey + `"><nostr-name pubkey="` + pubkey + `">` + shortID(pubkey) + `</nostr-name></a>`},2626 {"event", "nostr:" + bech32("note", idBytes), `<a href="/e/` + id + `">Quoted post</a>`},2727 {"article", "nostr:" + bech32("naddr", naddrData), `<a href="/a/30023:` + pubkey + `:hello">Article</a>`},2828 }@@ -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+}@@ -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+}@@ -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+}@@ -114,7 +114,7 @@ func TestSocialRespectsPagesFeature(t *testing.T) {
114114 if err != nil {115115 t.Fatal(err)116116 }117- for _, path := range []string{"/social", "/social/" + strings.Repeat("b", 64), "/social.json", "/articles"} {117+ for _, path := range []string{"/social", "/social/" + strings.Repeat("b", 64), "/social.json", "/articles", "/social/profile", "/social/podcasts.rss"} {118118 r := httptest.NewRecorder()119119 a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, path, nil))120120 if r.Code != http.StatusNotFound {@@ -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+`&cursor=next&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+}@@ -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+}@@ -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"}}&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}}@@ -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+`&cursor=next&kind=photos&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+}@@ -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)}@@ -17,7 +17,7 @@ import (
1717 // templateFS holds every page template. page.html owns the shell and the1818 // shared partials; the other files each define one group of tabs.1919 //20-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html chat.html chat-activity.html20+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html social_profile.html chat.html chat-activity.html2121 var templateFS embed.FS2222 2323 // styleCSS is inlined into every page so the UI needs no extra request and@@ -164,6 +164,9 @@ func railKind(tab string) string {
164164 if tab == "repo" {165165 return "repo"166166 }167+ if tab == "social" || tab == "social-thread" || tab == "social-profile" {168+ return "social"169+ }167170 if tab == "chat" || tab == "direct" || tab == "rooms" || tab == "room" || tab == "thread" {168171 return "rooms"169172 }@@ -323,6 +326,10 @@ func parseTemplateBase() (*template.Template, error) {
323326 "socialReactionCount": socialReactionCount,324327 "socialReacted": socialReacted,325328 "socialContext": socialContext,329+ "socialViews": socialViews,330+ "socialView": socialView,331+ "socialFeedPage": socialFeedPage,332+ "podcastFeedURL": podcastFeedURL,326333 "socialQuery": socialQuery,327334 "socialURL": socialURL,328335 "socialThreadURL": socialURL,