tinyrelaycommit

Commit 1944aa061621

browse files at this commit | history

1diff --git a/README.md b/README.md
2index 0b2825b..6005738 100644
3--- a/README.md
4+++ b/README.md
5@@ -71,6 +71,8 @@ See [Membership](docs/membership.md) for invites, joining with NIP-43 and review
6
7 See [Rooms](docs/rooms.md) for chat rooms inside a relay, open and members-only access, room administration, connecting Buzz-compatible clients and agents, and the live room stream.
8
9+See [Social](docs/social.md) for notes, long-form articles, NIP-10 and NIP-22 conversations, reactions, Markdown, media and mixed feeds.
10+
11 See [Installed app](docs/app.md) for installing the relay as an app, sharing files to it, opening Nostr links and device notifications.
12
13 See [Agent identities](docs/agents.md) for granting an assistant or bot its own scoped key, what the relay enforces and how to pause or revoke it.
14diff --git a/docs/social.md b/docs/social.md
15new file mode 100644
16index 0000000..110818a
17--- /dev/null
18+++ b/docs/social.md
19@@ -0,0 +1,37 @@
20+# Social
21+
22+**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.
23+
24+## Notes and articles
25+
26+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.
27+
28+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).
29+
30+## Conversations
31+
32+Replies to notes use kind 1 with the usual [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md) thread references. Replies to articles use kind 1111 comments with [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md): `A`, `K` and `P` identify the article address, while lowercase tags identify the immediate parent. Older kind 1 article replies remain readable when they carry an article `a` tag.
33+
34+Open a post to see its conversation. Replies are folded into the post's count in the feed, while the thread view lists comments in order and shows reaction totals with the same author and profile information.
35+
36+## Reactions
37+
38+Reactions are kind 7 events following [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md). The feed aggregates each reaction by content and counts one reaction per author and content for a post. Custom emoji from [NIP-30](https://github.com/nostr-protocol/nips/blob/master/30.md) use their image metadata and remain distinct when the same shortcode names different images. The signed-in person's reaction is marked separately, so the page can show which reactions they have already made.
39+
40+## Media and Markdown
41+
42+Attach images, video or audio from the composer, or paste a media URL. Uploaded media is stored through the relay's public Blossom file service and referenced in the event with [NIP-92](https://github.com/nostr-protocol/nips/blob/master/92.md) `imeta` tags. Alt text is included with the attachment metadata. Images, video and audio are shown inline when the browser can play them; unsupported media is offered as a direct link.
43+
44+Articles use Markdown with the relay's safe text renderer. Notes and comments remain plaintext, with line breaks and safe links preserved. Supported media references become native browser elements; unsafe HTML and unsupported link targets remain text.
45+
46+## Routes and feeds
47+
48+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`.
49+
50+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.
51+
52+## Relay template
53+
54+The **Social** tenant template enables member publishing for notes, comments, reactions and articles while keeping reads public. It also allows profiles and deletion events needed by common Nostr clients. Choose it when the relay should provide a shared publishing feed rather than an article-only site.
55+
56+Add the relay directly to any Nostr client that supports NIP-01 subscriptions and the event kinds above. The same events can therefore be viewed in the relay, Jumble, Primal and other Nostr clients.
57diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
58index e7a85a6..ca82cb3 100644
59--- a/internal/daemon/client_browse.go
60+++ b/internal/daemon/client_browse.go
61@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
62 }
63
64 func clientBrowseMethod(method string) bool {
65- return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
66+ return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
67 }
68
69 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
70@@ -67,6 +67,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
71 if jobBrowseMethod(method) {
72 return t.executeJobs(ctx, actor, method, params)
73 }
74+ if socialBrowseMethod(method) {
75+ return t.executeSocial(ctx, actor, method, params)
76+ }
77 q := clientBrowseRequest{}
78 if len(params) > 0 {
79 if err := json.Unmarshal(params[0], &q); err != nil {
80diff --git a/internal/daemon/social_browse.go b/internal/daemon/social_browse.go
81new file mode 100644
82index 0000000..19eb9f6
83--- /dev/null
84+++ b/internal/daemon/social_browse.go
85@@ -0,0 +1,596 @@
86+package daemon
87+
88+import (
89+ "context"
90+ "encoding/json"
91+ "errors"
92+ "fmt"
93+ "net/url"
94+ "sort"
95+ "strconv"
96+ "strings"
97+ "time"
98+
99+ "github.com/FelineStateMachine/tinyrelay/internal/event"
100+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
101+)
102+
103+type socialBrowseRequest struct {
104+ Kind string `json:"kind"`
105+ Author string `json:"author"`
106+ Query string `json:"q"`
107+ Cursor string `json:"cursor"`
108+ Limit int `json:"limit"`
109+ ID string `json:"id"`
110+ Address string `json:"address"`
111+}
112+
113+type socialItem struct {
114+ ID string `json:"id"`
115+ Kind int `json:"kind"`
116+ Author string `json:"author"`
117+ Pubkey string `json:"pubkey"`
118+ Tags [][]string `json:"tags,omitempty"`
119+ CreatedAt int64 `json:"created_at"`
120+ Content string `json:"content"`
121+ Title string `json:"title,omitempty"`
122+ Summary string `json:"summary,omitempty"`
123+ Image string `json:"image,omitempty"`
124+ Address string `json:"address,omitempty"`
125+ URL string `json:"url,omitempty"`
126+ ReadPath string `json:"read_path"`
127+ AuthorName string `json:"author_name,omitempty"`
128+ AuthorPicture string `json:"author_picture,omitempty"`
129+ ReplyCount int `json:"reply_count"`
130+ Reactions []socialReaction `json:"reactions,omitempty"`
131+ ParentID string `json:"parent_id,omitempty"`
132+ ParentAuthor string `json:"parent_author,omitempty"`
133+ ParentKind int `json:"parent_kind,omitempty"`
134+ ParentAddress string `json:"parent_address,omitempty"`
135+ RootID string `json:"root_id,omitempty"`
136+ RootAuthor string `json:"root_author,omitempty"`
137+ RootKind int `json:"root_kind,omitempty"`
138+ RootAddress string `json:"root_address,omitempty"`
139+ ReadMinutes int `json:"read_minutes,omitempty"`
140+ Event event.Event `json:"event"`
141+}
142+
143+type socialReaction struct {
144+ Content string `json:"content"`
145+ Count int `json:"count"`
146+ Reacted bool `json:"reacted,omitempty"`
147+ Image string `json:"image,omitempty"`
148+}
149+
150+func socialBrowseMethod(method string) bool {
151+ return method == "browsesocial" || method == "browsesocialthread"
152+}
153+
154+func (t *Tenant) executeSocial(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
155+ if !t.Policy().Features.Pages {
156+ return nil, errors.New("not found: social is disabled")
157+ }
158+ q := socialBrowseRequest{}
159+ if len(params) > 0 {
160+ if err := json.Unmarshal(params[0], &q); err != nil {
161+ return nil, fmt.Errorf("invalid: social parameters: %w", err)
162+ }
163+ }
164+ if q.Limit <= 0 || q.Limit > 100 {
165+ q.Limit = 30
166+ }
167+ if q.Author != "" && !socialHex(q.Author) {
168+ return nil, errors.New("invalid: social author")
169+ }
170+ if method == "browsesocialthread" {
171+ return t.browseSocialThread(ctx, actor, q)
172+ }
173+ return t.browseSocial(ctx, actor, q)
174+}
175+
176+func socialHex(value string) bool {
177+ return len(value) == 64 && strings.Trim(value, "0123456789abcdef") == ""
178+}
179+func socialAddress(row event.Event) string {
180+ if row.Kind == 30023 {
181+ return "30023:" + row.PubKey + ":" + event.Tag(row, "d")
182+ }
183+ return ""
184+}
185+func socialCursor(row event.Event) string {
186+ return collaborationCursor(collaborationItem{CreatedAt: row.CreatedAt, ID: row.ID})
187+}
188+func socialOlder(a, b event.Event) bool {
189+ if a.CreatedAt == b.CreatedAt {
190+ return a.ID < b.ID
191+ }
192+ return a.CreatedAt > b.CreatedAt
193+}
194+
195+func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {
196+ kinds := []int{1, 30023}
197+ switch q.Kind {
198+ case "", "all":
199+ case "notes":
200+ kinds = []int{1}
201+ case "articles":
202+ kinds = []int{30023}
203+ default:
204+ return nil, errors.New("invalid: social kind")
205+ }
206+ cursor, err := parseCollaborationCursor(q.Cursor)
207+ if err != nil {
208+ return nil, err
209+ }
210+ filter := event.Filter{Kinds: kinds}
211+ if q.Author != "" {
212+ filter.Authors = []string{q.Author}
213+ }
214+ rows := []event.Event{}
215+ next := ""
216+ session := browseSession(t, actor)
217+ for scan := 0; scan < 20; scan++ {
218+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})
219+ if err != nil {
220+ return nil, err
221+ }
222+ for _, row := range page.Events {
223+ cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}
224+ if socialFeedRoot(row) && t.gate.CanSee(ctx, row, session, &filter) && socialMatches(row, q.Query) {
225+ rows = append(rows, row)
226+ }
227+ if len(rows) > q.Limit {
228+ break
229+ }
230+ }
231+ if len(rows) > q.Limit {
232+ next = socialCursor(rows[q.Limit-1])
233+ rows = rows[:q.Limit]
234+ break
235+ }
236+ if !page.More || len(page.Events) == 0 {
237+ break
238+ }
239+ if scan == 19 && cursor != nil {
240+ next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})
241+ }
242+ }
243+ items, err := t.socialItems(ctx, actor, rows)
244+ if err != nil {
245+ return nil, err
246+ }
247+ stats := map[string]int{"notes": 0, "articles": 0, "comments": 0}
248+ for _, item := range items {
249+ if item.Kind == 30023 {
250+ stats["articles"]++
251+ } else {
252+ stats["notes"]++
253+ }
254+ stats["comments"] += item.ReplyCount
255+ }
256+ return map[string]any{"items": items, "next_cursor": next, "stats": stats}, nil
257+}
258+
259+func socialFeedRoot(row event.Event) bool {
260+ if row.Kind == 30023 {
261+ return true
262+ }
263+ if row.Kind != 1 || event.Tag(row, "h") != "" {
264+ return false
265+ }
266+ ref := socialReference(row)
267+ return ref.parent == "" && ref.parentAddress == ""
268+}
269+func socialMatches(row event.Event, query string) bool {
270+ needle := strings.ToLower(strings.TrimSpace(query))
271+ return needle == "" || strings.Contains(strings.ToLower(row.Content+" "+event.Tag(row, "title")+" "+event.Tag(row, "summary")), needle)
272+}
273+
274+// socialEvents scans only the requested references and applies the same per-event
275+// visibility rules as relay subscriptions, including profiles and reactions.
276+func (t *Tenant) socialEvents(ctx context.Context, actor string, filters []event.Filter) ([]event.Event, error) {
277+ session := browseSession(t, actor)
278+ out := []event.Event{}
279+ seen := map[string]bool{}
280+ for _, filter := range filters {
281+ var cursor *storage.EventCursor
282+ for {
283+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 200, Before: cursor})
284+ if err != nil {
285+ return nil, err
286+ }
287+ for _, row := range page.Events {
288+ if !seen[row.ID] && t.gate.CanSee(ctx, row, session, &filter) {
289+ seen[row.ID] = true
290+ out = append(out, row)
291+ }
292+ }
293+ if !page.More || len(page.Events) == 0 {
294+ break
295+ }
296+ last := page.Events[len(page.Events)-1]
297+ cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}
298+ }
299+ }
300+ sort.Slice(out, func(i, j int) bool { return socialOlder(out[i], out[j]) })
301+ return out, nil
302+}
303+func socialActivityFilters(rows []event.Event) []event.Filter {
304+ ids, addresses := []string{}, []string{}
305+ for _, row := range rows {
306+ ids = append(ids, row.ID)
307+ if address := socialAddress(row); address != "" {
308+ addresses = append(addresses, address)
309+ }
310+ }
311+ filters := []event.Filter{{Kinds: []int{1, 7, 1111}, Tags: map[string][]string{"e": ids}}, {Kinds: []int{1111}, Tags: map[string][]string{"E": ids}}}
312+ if len(addresses) > 0 {
313+ filters = append(filters, event.Filter{Kinds: []int{1, 7, 1111}, Tags: map[string][]string{"a": addresses}}, event.Filter{Kinds: []int{1111}, Tags: map[string][]string{"A": addresses}})
314+ }
315+ return filters
316+}
317+func (t *Tenant) socialItems(ctx context.Context, actor string, rows []event.Event) ([]socialItem, error) {
318+ if len(rows) == 0 {
319+ return []socialItem{}, nil
320+ }
321+ extra, err := t.socialEvents(ctx, actor, socialActivityFilters(rows))
322+ if err != nil {
323+ return nil, err
324+ }
325+ items := socialItemsWithRefs(rows, extra, actor, nil)
326+ authors := []string{}
327+ seen := map[string]bool{}
328+ for _, row := range rows {
329+ if !seen[row.PubKey] {
330+ seen[row.PubKey] = true
331+ authors = append(authors, row.PubKey)
332+ }
333+ }
334+ profiles, err := t.socialEvents(ctx, actor, []event.Filter{{Kinds: []int{0}, Authors: authors}})
335+ if err != nil {
336+ return nil, err
337+ }
338+ names := map[string][2]string{}
339+ for _, profile := range profiles {
340+ var p struct {
341+ Name string `json:"name"`
342+ Display string `json:"display_name"`
343+ Picture string `json:"picture"`
344+ }
345+ if json.Unmarshal([]byte(profile.Content), &p) == nil {
346+ if p.Display == "" {
347+ p.Display = p.Name
348+ }
349+ names[profile.PubKey] = [2]string{p.Display, p.Picture}
350+ }
351+ }
352+ for i := range items {
353+ items[i].AuthorName, items[i].AuthorPicture = names[items[i].Pubkey][0], names[items[i].Pubkey][1]
354+ }
355+ return items, nil
356+}
357+
358+func socialItemsWithRefs(rows, extra []event.Event, actor string, _ map[string]string) []socialItem {
359+ items := make([]socialItem, len(rows))
360+ index := map[string]int{}
361+ for i, row := range rows {
362+ items[i] = socialItemFrom(row)
363+ index[row.ID] = i
364+ if a := socialAddress(row); a != "" {
365+ index[a] = i
366+ }
367+ }
368+ type reactionKey struct {
369+ index int
370+ author, content string
371+ }
372+ seen := map[reactionKey]bool{}
373+ counts := map[int]map[string]socialReaction{}
374+ for _, row := range extra {
375+ if row.Kind == 7 {
376+ ref := socialReference(row)
377+ target := ref.parent
378+ if _, ok := index[ref.parentAddress]; ok {
379+ target = ref.parentAddress
380+ }
381+ i, ok := index[target]
382+ if !ok {
383+ continue
384+ }
385+ content, image := socialReactionValue(row)
386+ key := reactionKey{i, row.PubKey, content + "\x00" + image}
387+ if seen[key] {
388+ continue
389+ }
390+ seen[key] = true
391+ if counts[i] == nil {
392+ counts[i] = map[string]socialReaction{}
393+ }
394+ variant := content + "\x00" + image
395+ r := counts[i][variant]
396+ r.Content = content
397+ r.Image = image
398+ r.Count++
399+ r.Reacted = r.Reacted || row.PubKey == actor
400+ counts[i][variant] = r
401+ } else if row.Kind == 1 || row.Kind == 1111 {
402+ for i, post := range rows {
403+ if socialCommentFor(row, post) {
404+ items[i].ReplyCount++
405+ }
406+ }
407+ }
408+ }
409+ for i, group := range counts {
410+ for _, r := range group {
411+ items[i].Reactions = append(items[i].Reactions, r)
412+ }
413+ sort.Slice(items[i].Reactions, func(a, b int) bool {
414+ if items[i].Reactions[a].Content == items[i].Reactions[b].Content {
415+ return items[i].Reactions[a].Image < items[i].Reactions[b].Image
416+ }
417+ return items[i].Reactions[a].Content < items[i].Reactions[b].Content
418+ })
419+ }
420+ return items
421+}
422+
423+// socialReactionValue applies NIP-30 custom emoji metadata only when the
424+// reaction content and emoji tag form a valid, safe pair. Invalid metadata is
425+// retained as ordinary reaction text so it cannot turn into an image URL.
426+func socialReactionValue(row event.Event) (string, string) {
427+ content := row.Content
428+ if content == "" {
429+ return "+", ""
430+ }
431+ if len(content) < 3 || content[0] != ':' || content[len(content)-1] != ':' {
432+ return content, ""
433+ }
434+ shortcode := content[1 : len(content)-1]
435+ if !socialEmojiShortcode(shortcode) {
436+ return content, ""
437+ }
438+ for _, tag := range row.Tags {
439+ if len(tag) < 3 || tag[0] != "emoji" || tag[1] != shortcode {
440+ continue
441+ }
442+ if socialEmojiURL(tag[2]) {
443+ return content, tag[2]
444+ }
445+ }
446+ return content, ""
447+}
448+
449+func socialEmojiShortcode(value string) bool {
450+ if value == "" {
451+ return false
452+ }
453+ for _, char := range value {
454+ if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' {
455+ return false
456+ }
457+ }
458+ return true
459+}
460+
461+func socialEmojiURL(value string) bool {
462+ parsed, err := url.Parse(value)
463+ return err == nil && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.Hostname() != "" && parsed.User == nil
464+}
465+
466+type socialRef struct {
467+ root, parent, rootAuthor, parentAuthor string
468+ rootKind, parentKind int
469+ rootAddress, parentAddress string
470+}
471+
472+func tagValue(tag []string, index int) string {
473+ if len(tag) > index {
474+ return tag[index]
475+ }
476+ return ""
477+}
478+func atoiTag(value string) int { n, _ := strconv.Atoi(value); return n }
479+func addressAuthor(address string) string {
480+ parts := strings.SplitN(address, ":", 3)
481+ if len(parts) == 3 {
482+ return parts[1]
483+ }
484+ return ""
485+}
486+func socialReference(row event.Event) socialRef {
487+ ref := socialRef{}
488+ if row.Kind == 1 {
489+ var legacy [][]string
490+ marked := false
491+ for _, tag := range row.Tags {
492+ if len(tag) < 2 {
493+ continue
494+ }
495+ if tag[0] == "a" {
496+ ref.parentAddress = tag[1]
497+ ref.rootAddress = tag[1]
498+ continue
499+ }
500+ if tag[0] != "e" {
501+ continue
502+ }
503+ switch tagValue(tag, 3) {
504+ case "root":
505+ ref.root, ref.rootAuthor = tag[1], tagValue(tag, 4)
506+ marked = true
507+ case "reply":
508+ ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 4)
509+ marked = true
510+ case "":
511+ legacy = append(legacy, tag)
512+ }
513+ }
514+ if !marked && len(legacy) > 0 {
515+ ref.root = legacy[0][1]
516+ ref.parent = legacy[len(legacy)-1][1]
517+ }
518+ if ref.root == "" {
519+ ref.root, ref.rootAuthor = ref.parent, ref.parentAuthor
520+ }
521+ if ref.parent == "" {
522+ ref.parent, ref.parentAuthor = ref.root, ref.rootAuthor
523+ }
524+ ref.rootKind, ref.parentKind = 1, 1
525+ return ref
526+ }
527+ for _, tag := range row.Tags {
528+ if len(tag) < 2 {
529+ continue
530+ }
531+ switch tag[0] {
532+ case "E":
533+ ref.root, ref.rootAuthor = tag[1], tagValue(tag, 3)
534+ case "e":
535+ ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 3)
536+ case "A":
537+ ref.rootAddress = tag[1]
538+ case "a":
539+ ref.parentAddress = tag[1]
540+ case "K":
541+ ref.rootKind = atoiTag(tag[1])
542+ case "k":
543+ ref.parentKind = atoiTag(tag[1])
544+ case "P":
545+ ref.rootAuthor = tag[1]
546+ case "p":
547+ ref.parentAuthor = tag[1]
548+ }
549+ }
550+ if ref.rootAddress != "" && ref.rootAuthor == "" {
551+ ref.rootAuthor = addressAuthor(ref.rootAddress)
552+ }
553+ if ref.parentAddress != "" && ref.parentAuthor == "" {
554+ ref.parentAuthor = addressAuthor(ref.parentAddress)
555+ }
556+ return ref
557+}
558+
559+func socialCommentFor(comment, post event.Event) bool {
560+ ref := socialReference(comment)
561+ if comment.Kind == 1 && post.Kind == 1 {
562+ return ref.parent == post.ID || ref.root == post.ID
563+ }
564+ if post.Kind == 30023 {
565+ address := socialAddress(post)
566+ if comment.Kind == 1111 {
567+ return ref.rootKind == 30023 && (ref.rootAddress == address || ref.root == post.ID)
568+ }
569+ // Older long-form clients used kind 1 with an article a tag. Read these
570+ // comments for compatibility, but new comments always use NIP-22.
571+ return comment.Kind == 1 && ref.rootAddress == address
572+ }
573+ if post.Kind == 1111 {
574+ return comment.Kind == 1111 && ref.parent == post.ID && ref.parentKind == 1111
575+ }
576+ return false
577+}
578+func socialItemFrom(row event.Event) socialItem {
579+ item := socialItem{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Pubkey: row.PubKey, Tags: row.Tags, CreatedAt: row.CreatedAt, Content: row.Content, Title: event.Tag(row, "title"), Summary: event.Tag(row, "summary"), Image: event.Tag(row, "image"), Event: row, Address: socialAddress(row)}
580+ item.ReadPath = "/social/" + row.ID
581+ if item.Address != "" {
582+ item.ReadPath = "/social?address=" + url.QueryEscape(item.Address)
583+ item.ReadMinutes = maxInt(1, (len(strings.Fields(row.Content))+219)/220)
584+ }
585+ ref := socialReference(row)
586+ item.ParentID, item.ParentAuthor, item.ParentKind, item.ParentAddress = ref.parent, ref.parentAuthor, ref.parentKind, ref.parentAddress
587+ if ref.root == "" && ref.rootAddress == "" {
588+ ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress = row.ID, row.PubKey, row.Kind, item.Address
589+ }
590+ item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress
591+ return item
592+}
593+func maxInt(a, b int) int {
594+ if a > b {
595+ return a
596+ }
597+ return b
598+}
599+func socialFilter(identifier string) (event.Filter, error) {
600+ if socialHex(identifier) {
601+ return event.Filter{IDs: []string{identifier}}, nil
602+ }
603+ parts := strings.SplitN(identifier, ":", 3)
604+ if len(parts) != 3 || parts[0] != "30023" || !socialHex(parts[1]) {
605+ return event.Filter{}, errors.New("invalid: social post address")
606+ }
607+ return event.Filter{Kinds: []int{30023}, Authors: []string{parts[1]}, Tags: map[string][]string{"d": {parts[2]}}}, nil
608+}
609+func (t *Tenant) socialPost(ctx context.Context, actor, identifier string) (event.Event, error) {
610+ filter, err := socialFilter(identifier)
611+ if err != nil {
612+ return event.Event{}, err
613+ }
614+ rows, err := t.socialEvents(ctx, actor, []event.Filter{filter})
615+ if err != nil {
616+ return event.Event{}, err
617+ }
618+ if len(rows) != 1 || (rows[0].Kind != 1 && rows[0].Kind != 30023 && rows[0].Kind != 1111) || event.Tag(rows[0], "h") != "" {
619+ return event.Event{}, errors.New("not found: social post")
620+ }
621+ if rows[0].Kind == 1111 && socialReference(rows[0]).rootKind != 30023 {
622+ return event.Event{}, errors.New("not found: social comment")
623+ }
624+ return rows[0], nil
625+}
626+func (t *Tenant) browseSocialThread(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {
627+ identifier := q.ID
628+ if identifier == "" {
629+ identifier = q.Address
630+ }
631+ post, err := t.socialPost(ctx, actor, identifier)
632+ if err != nil {
633+ return nil, err
634+ }
635+ items, err := t.socialItems(ctx, actor, []event.Event{post})
636+ if err != nil {
637+ return nil, err
638+ }
639+ item := items[0]
640+ if post.Kind == 1111 || post.Kind == 1 && item.ParentID != "" {
641+ rootIdentifier := item.RootID
642+ if item.RootAddress != "" {
643+ rootIdentifier = item.RootAddress
644+ }
645+ root, rootErr := t.socialPost(ctx, actor, rootIdentifier)
646+ if post.Kind == 1111 && rootErr != nil {
647+ return nil, errors.New("not found: article for comment")
648+ }
649+ if rootErr == nil {
650+ item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = root.ID, root.PubKey, root.Kind, socialAddress(root)
651+ }
652+ }
653+ cursor, err := parseCollaborationCursor(q.Cursor)
654+ if err != nil {
655+ return nil, err
656+ }
657+ activity, err := t.socialEvents(ctx, actor, socialActivityFilters([]event.Event{post}))
658+ if err != nil {
659+ return nil, err
660+ }
661+ replies := []event.Event{}
662+ for _, row := range activity {
663+ if !socialCommentFor(row, post) {
664+ continue
665+ }
666+ if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) {
667+ continue
668+ }
669+ replies = append(replies, row)
670+ }
671+ next := ""
672+ if len(replies) > q.Limit {
673+ next = socialCursor(replies[q.Limit-1])
674+ replies = replies[:q.Limit]
675+ }
676+ comments, err := t.socialItems(ctx, actor, replies)
677+ if err != nil {
678+ return nil, err
679+ }
680+ return map[string]any{"post": item, "comments": comments, "next_cursor": next}, nil
681+}
682diff --git a/internal/daemon/social_browse_test.go b/internal/daemon/social_browse_test.go
683new file mode 100644
684index 0000000..a67af33
685--- /dev/null
686+++ b/internal/daemon/social_browse_test.go
687@@ -0,0 +1,137 @@
688+package daemon
689+
690+import (
691+ "context"
692+ "encoding/json"
693+ "strings"
694+ "testing"
695+
696+ "github.com/FelineStateMachine/tinyrelay/internal/event"
697+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
698+)
699+
700+func TestSocialBrowseChronologicalAndThread(t *testing.T) {
701+ _, tenant := testTenant(t)
702+ owner := tenant.Policy().Owner
703+ secret := strings.Repeat("7", 63) + "8"
704+ note := signedEvent(t, secret, 1, 200, [][]string{{"subject", "A note"}}, "hello markdown")
705+ article := signedEvent(t, secret, 30023, 100, [][]string{{"d", "essay"}, {"title", "An essay"}, {"summary", "reference"}}, "long form")
706+ comment := signedEvent(t, secret, 1, 300, [][]string{{"e", note.ID, "", "root", note.PubKey}}, "reply")
707+ for _, row := range []event.Event{note, article, comment} {
708+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
709+ t.Fatal(err)
710+ }
711+ }
712+ raw, err := json.Marshal(map[string]any{"limit": 2})
713+ if err != nil {
714+ t.Fatal(err)
715+ }
716+ value, err := tenant.Execute(context.Background(), owner, "browsesocial", []json.RawMessage{raw})
717+ if err != nil {
718+ t.Fatal(err)
719+ }
720+ feed := value.(map[string]any)
721+ items := feed["items"].([]socialItem)
722+ if len(items) != 2 || items[0].ID != note.ID || items[1].ID != article.ID {
723+ t.Fatalf("items = %#v", items)
724+ }
725+ check, _ := tenant.store.Query(context.Background(), event.Filter{Kinds: []int{1}, Tags: map[string][]string{"e": []string{note.ID}}}, storage.QueryOptions{Now: 999, Access: storage.Access{All: true}})
726+ if len(check.Events) != 1 {
727+ t.Fatalf("stored comments = %d", len(check.Events))
728+ }
729+ if items[0].ReplyCount != 1 {
730+ t.Fatalf("reply count = %d", items[0].ReplyCount)
731+ }
732+ detail, err := tenant.Execute(context.Background(), owner, "browsesocialthread", []json.RawMessage{json.RawMessage(`{"id":"` + note.ID + `"}`)})
733+ if err != nil {
734+ t.Fatal(err)
735+ }
736+ if len(detail.(map[string]any)["comments"].([]socialItem)) != 1 {
737+ t.Fatal("comment missing")
738+ }
739+}
740+
741+func TestSocialBrowseDoesNotExposeUnrelatedComments(t *testing.T) {
742+ _, tenant := testTenant(t)
743+ owner := tenant.Policy().Owner
744+ secret := strings.Repeat("8", 63) + "9"
745+ note := signedEvent(t, secret, 1, 100, nil, "note")
746+ comment := signedEvent(t, secret, 1111, 200, [][]string{{"e", strings.Repeat("a", 64)}, {"k", "1"}}, "private-looking")
747+ for _, row := range []event.Event{note, comment} {
748+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
749+ t.Fatal(err)
750+ }
751+ }
752+ value, err := tenant.Execute(context.Background(), owner, "browsesocial", nil)
753+ if err != nil {
754+ t.Fatal(err)
755+ }
756+ if len(value.(map[string]any)["items"].([]socialItem)) != 1 {
757+ t.Fatal("unrelated comment leaked into feed")
758+ }
759+}
760+
761+func TestSocialBrowseNIP10NIP22AndRevisionReactions(t *testing.T) {
762+ _, tenant := testTenant(t)
763+ owner := tenant.Policy().Owner
764+ authorSecret := strings.Repeat("1", 63) + "2"
765+ otherSecret := strings.Repeat("3", 63) + "4"
766+ note := signedEvent(t, authorSecret, 1, 500, nil, "root note")
767+ legacyReply := signedEvent(t, otherSecret, 1, 501, [][]string{{"e", note.ID, ""}, {"e", note.ID, ""}}, "legacy reply")
768+ nestedReply := signedEvent(t, authorSecret, 1, 502, [][]string{{"e", note.ID, "", "root", note.PubKey}, {"e", legacyReply.ID, "", "reply", legacyReply.PubKey}}, "nested reply")
769+ articleOld := signedEvent(t, authorSecret, 30023, 400, [][]string{{"d", "essay:edition"}, {"title", "Old"}}, "old article")
770+ article := signedEvent(t, authorSecret, 30023, 600, [][]string{{"d", "essay:edition"}, {"title", "Current"}}, "current article")
771+ articleAddress := "30023:" + article.PubKey + ":essay:edition"
772+ articleComment := signedEvent(t, otherSecret, 1111, 601, [][]string{
773+ {"A", articleAddress}, {"E", articleOld.ID, "", articleOld.PubKey}, {"K", "30023"}, {"P", article.PubKey},
774+ {"a", articleAddress}, {"e", articleOld.ID, "", articleOld.PubKey}, {"k", "30023"}, {"p", articleOld.PubKey},
775+ }, "article comment")
776+ reactionOld := signedEvent(t, otherSecret, 7, 602, [][]string{{"e", articleOld.ID}}, "")
777+ reactionNew := signedEvent(t, otherSecret, 7, 603, [][]string{{"e", article.ID}, {"a", articleAddress}}, "")
778+ strangerComment := signedEvent(t, otherSecret, 1111, 604, [][]string{{"E", strings.Repeat("f", 64)}, {"K", "30023"}}, "unrelated")
779+ for _, row := range []event.Event{note, legacyReply, nestedReply, articleOld, article, articleComment, reactionOld, reactionNew, strangerComment} {
780+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
781+ t.Fatal(err)
782+ }
783+ }
784+ if check, queryErr := tenant.store.Query(context.Background(), event.Filter{Kinds: []int{1111}, Tags: map[string][]string{"A": {articleAddress}}}, storage.QueryOptions{Now: 999, Access: storage.Access{All: true}}); queryErr != nil || len(check.Events) != 1 {
785+ t.Fatalf("article comment query = %d, %v", len(check.Events), queryErr)
786+ }
787+ value, err := tenant.Execute(context.Background(), owner, "browsesocial", []json.RawMessage{rawJSON(map[string]any{"limit": 20})})
788+ if err != nil {
789+ t.Fatal(err)
790+ }
791+ feed := value.(map[string]any)
792+ items := feed["items"].([]socialItem)
793+ if len(items) != 2 || items[0].Kind != 30023 || items[1].Kind != 1 {
794+ t.Fatalf("feed roots = %#v", items)
795+ }
796+ articleItem := items[0]
797+ if articleItem.Title != "Current" || articleItem.Address != articleAddress || articleItem.ReplyCount != 1 {
798+ t.Fatalf("article item = %#v", articleItem)
799+ }
800+ if len(articleItem.Reactions) != 1 || articleItem.Reactions[0].Content != "+" || articleItem.Reactions[0].Count != 1 {
801+ t.Fatalf("article reactions = %#v", articleItem.Reactions)
802+ }
803+ if items[1].ReplyCount != 2 {
804+ t.Fatalf("note replies = %#v", items[1])
805+ }
806+ if items[1].RootID != items[1].ID || items[1].ParentID != "" || items[1].RootKind != 1 {
807+ t.Fatalf("root metadata = %#v", items[1])
808+ }
809+ detail, err := tenant.Execute(context.Background(), owner, "browsesocialthread", []json.RawMessage{rawJSON(map[string]any{"id": nestedReply.ID, "limit": 20})})
810+ if err != nil {
811+ t.Fatal(err)
812+ }
813+ selected := detail.(map[string]any)["post"].(socialItem)
814+ if selected.ParentID != legacyReply.ID || selected.RootID != note.ID || selected.ParentKind != 1 {
815+ t.Fatalf("nested metadata = %#v", selected)
816+ }
817+ articleDetail, err := tenant.Execute(context.Background(), owner, "browsesocialthread", []json.RawMessage{rawJSON(map[string]any{"address": articleAddress, "limit": 20})})
818+ if err != nil {
819+ t.Fatal(err)
820+ }
821+ if len(articleDetail.(map[string]any)["comments"].([]socialItem)) != 1 {
822+ t.Fatal("article comment missing")
823+ }
824+}
825diff --git a/internal/daemon/social_protocol_test.go b/internal/daemon/social_protocol_test.go
826new file mode 100644
827index 0000000..355d43e
828--- /dev/null
829+++ b/internal/daemon/social_protocol_test.go
830@@ -0,0 +1,72 @@
831+package daemon
832+
833+import (
834+ "context"
835+ "encoding/json"
836+ "strings"
837+ "testing"
838+
839+ "github.com/FelineStateMachine/tinyrelay/internal/event"
840+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
841+)
842+
843+// These fixtures use the wire shapes from NIP-10 and NIP-22 rather than the
844+// relay's internal metadata vocabulary. They are intentionally signed and
845+// executed through the tenant RPC so filtering, moderation and visibility are
846+// exercised together.
847+func TestSocialProtocolReferencesAndVisibility(t *testing.T) {
848+ _, tenant := testTenant(t)
849+ owner := tenant.Policy().Owner
850+ authorSecret := strings.Repeat("1", 63) + "2"
851+ commenterSecret := strings.Repeat("3", 63) + "4"
852+ note := signedEvent(t, authorSecret, 1, 500, nil, "root note")
853+ reply := signedEvent(t, commenterSecret, 1, 501, [][]string{{"e", note.ID, "", "root", note.PubKey}}, "reply")
854+ nested := signedEvent(t, authorSecret, 1, 502, [][]string{{"e", note.ID, "", "root", note.PubKey}, {"e", reply.ID, "", "reply", reply.PubKey}}, "nested")
855+ article := signedEvent(t, authorSecret, 30023, 600, [][]string{{"d", "essay:edition"}, {"title", "Current"}}, "article")
856+ address := "30023:" + article.PubKey + ":essay:edition"
857+ articleComment := signedEvent(t, commenterSecret, 1111, 601, [][]string{
858+ {"A", address}, {"K", "30023"}, {"P", article.PubKey},
859+ {"a", address}, {"k", "30023"}, {"p", article.PubKey},
860+ }, "article comment")
861+ reaction := signedEvent(t, commenterSecret, 7, 602, [][]string{{"e", article.ID}, {"a", address}}, "")
862+ unrelated := signedEvent(t, commenterSecret, 1111, 603, [][]string{{"E", strings.Repeat("f", 64)}, {"K", "30023"}}, "unrelated")
863+ for _, row := range []event.Event{note, reply, nested, article, articleComment, reaction, unrelated} {
864+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
865+ t.Fatal(err)
866+ }
867+ }
868+ value, err := tenant.Execute(context.Background(), owner, "browsesocial", []json.RawMessage{rawJSON(map[string]any{"limit": 20})})
869+ if err != nil {
870+ t.Fatal(err)
871+ }
872+ items := value.(map[string]any)["items"].([]socialItem)
873+ if len(items) != 2 || items[0].Address != address || items[1].ID != note.ID {
874+ t.Fatalf("feed roots = %#v", items)
875+ }
876+ if items[0].ReplyCount != 1 || len(items[0].Reactions) != 1 || items[0].Reactions[0].Content != "+" {
877+ t.Fatalf("article folding = %#v", items[0])
878+ }
879+ detail, err := tenant.Execute(context.Background(), owner, "browsesocialthread", []json.RawMessage{rawJSON(map[string]any{"id": nested.ID, "limit": 20})})
880+ if err != nil {
881+ t.Fatal(err)
882+ }
883+ selected := detail.(map[string]any)["post"].(socialItem)
884+ if selected.ParentID != reply.ID || selected.RootID != note.ID {
885+ t.Fatalf("thread metadata = %#v", selected)
886+ }
887+ rootDetail, err := tenant.Execute(context.Background(), owner, "browsesocialthread", []json.RawMessage{rawJSON(map[string]any{"id": note.ID})})
888+ if err != nil {
889+ t.Fatal(err)
890+ }
891+ comments := rootDetail.(map[string]any)["comments"].([]socialItem)
892+ if len(comments) != 2 || comments[0].ParentID != reply.ID {
893+ t.Fatalf("root conversation = %#v", comments)
894+ }
895+ articleDetail, err := tenant.Execute(context.Background(), owner, "browsesocialthread", []json.RawMessage{rawJSON(map[string]any{"address": address, "limit": 20})})
896+ if err != nil {
897+ t.Fatal(err)
898+ }
899+ if len(articleDetail.(map[string]any)["comments"].([]socialItem)) != 1 {
900+ t.Fatal("article comment missing")
901+ }
902+}
903diff --git a/internal/daemon/social_reactions_test.go b/internal/daemon/social_reactions_test.go
904new file mode 100644
905index 0000000..a46a997
906--- /dev/null
907+++ b/internal/daemon/social_reactions_test.go
908@@ -0,0 +1,57 @@
909+package daemon
910+
911+import (
912+ "strings"
913+ "testing"
914+
915+ "github.com/FelineStateMachine/tinyrelay/internal/event"
916+)
917+
918+func TestSocialReactionValueNIP30(t *testing.T) {
919+ url := "https://cdn.example/percent.png"
920+ reaction := event.Event{Content: ":100percent:", Tags: [][]string{{"emoji", "100percent", url}}}
921+ content, image := socialReactionValue(reaction)
922+ if content != ":100percent:" || image != url {
923+ t.Fatalf("custom emoji = %q, %q", content, image)
924+ }
925+
926+ for _, invalid := range []event.Event{
927+ {Content: ":100percent:", Tags: [][]string{{"emoji", "100percent", "javascript:alert(1)"}}},
928+ {Content: ":100.percent:", Tags: [][]string{{"emoji", "100.percent", url}}},
929+ {Content: ":unknown:", Tags: [][]string{{"emoji", "other", url}}},
930+ } {
931+ content, image := socialReactionValue(invalid)
932+ if image != "" || content == "" {
933+ t.Fatalf("invalid custom emoji = %q, %q", content, image)
934+ }
935+ }
936+}
937+
938+func TestSocialReactionsGroupCustomEmojiByImage(t *testing.T) {
939+ secret := strings.Repeat("1", 63) + "2"
940+ other := strings.Repeat("3", 63) + "4"
941+ third := strings.Repeat("5", 63) + "6"
942+ actor, err := event.PublicKey(secret)
943+ if err != nil {
944+ t.Fatal(err)
945+ }
946+ post := signedEvent(t, secret, 1, 100, nil, "post")
947+ firstURL := "https://cdn.example/one.png"
948+ secondURL := "https://cdn.example/two.png"
949+ extra := []event.Event{
950+ signedEvent(t, other, 7, 101, [][]string{{"e", post.ID}, {"emoji", "party", firstURL}}, ":party:"),
951+ signedEvent(t, other, 7, 102, [][]string{{"e", post.ID}, {"emoji", "party", firstURL}}, ":party:"),
952+ signedEvent(t, third, 7, 102, [][]string{{"e", post.ID}, {"emoji", "party", firstURL}}, ":party:"),
953+ signedEvent(t, secret, 7, 103, [][]string{{"e", post.ID}, {"emoji", "party", secondURL}}, ":party:"),
954+ }
955+ items := socialItemsWithRefs([]event.Event{post}, extra, actor, nil)
956+ if len(items) != 1 || len(items[0].Reactions) != 2 {
957+ t.Fatalf("reactions = %#v", items)
958+ }
959+ if items[0].Reactions[0].Image != firstURL || items[0].Reactions[0].Count != 2 || items[0].Reactions[0].Reacted {
960+ t.Fatalf("first custom reaction = %#v", items[0].Reactions[0])
961+ }
962+ if items[0].Reactions[1].Image != secondURL || items[0].Reactions[1].Count != 1 || !items[0].Reactions[1].Reacted {
963+ t.Fatalf("second custom reaction = %#v", items[0].Reactions[1])
964+ }
965+}
966diff --git a/internal/daemon/social_regression_test.go b/internal/daemon/social_regression_test.go
967new file mode 100644
968index 0000000..b675961
969--- /dev/null
970+++ b/internal/daemon/social_regression_test.go
971@@ -0,0 +1,130 @@
972+package daemon
973+
974+import (
975+ "context"
976+ "encoding/json"
977+ "strings"
978+ "testing"
979+
980+ "github.com/FelineStateMachine/tinyrelay/internal/event"
981+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
982+)
983+
984+func saveSocialRegression(t *testing.T, tenant *Tenant, rows ...event.Event) {
985+ t.Helper()
986+ for _, row := range rows {
987+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
988+ t.Fatal(err)
989+ }
990+ }
991+}
992+
993+func socialRegressionBrowse(t *testing.T, tenant *Tenant, params map[string]any) map[string]any {
994+ return socialRegressionBrowseAs(t, tenant, tenant.Policy().Owner, params)
995+}
996+
997+func socialRegressionBrowseAs(t *testing.T, tenant *Tenant, actor string, params map[string]any) map[string]any {
998+ t.Helper()
999+ value, err := tenant.Execute(context.Background(), actor, "browsesocial", []json.RawMessage{rawJSON(params)})
1000+ if err != nil {
1001+ t.Fatal(err)
1002+ }
1003+ return value.(map[string]any)
1004+}
1005+
1006+func TestSocialRegressionHiddenActivityAndProfilesAreNotFolded(t *testing.T) {
1007+ _, tenant := testTenant(t)
1008+ secret := strings.Repeat("a", 63) + "b"
1009+ note := signedEvent(t, secret, 1, 100, nil, "visible note")
1010+ comment := signedEvent(t, secret, 1, 101, [][]string{{"e", note.ID, "", "root", note.PubKey}}, "hidden comment")
1011+ reaction := signedEvent(t, secret, 7, 102, [][]string{{"e", note.ID}, {"p", note.PubKey}}, "+")
1012+ profile := signedEvent(t, secret, 0, 103, nil, `{"name":"leaked name","picture":"https://example.test/avatar.png"}`)
1013+ saveSocialRegression(t, tenant, note, comment, reaction, profile)
1014+ visible := socialRegressionBrowse(t, tenant, map[string]any{"limit": 10})["items"].([]socialItem)
1015+ if len(visible) != 1 || visible[0].ReplyCount != 1 || len(visible[0].Reactions) != 1 || visible[0].AuthorName != "leaked name" {
1016+ t.Fatalf("visible activity was not folded before hiding: %#v", visible)
1017+ }
1018+ ctx := context.Background()
1019+ for _, row := range []event.Event{comment, reaction, profile} {
1020+ if _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'regression')`, row.ID); err != nil {
1021+ t.Fatal(err)
1022+ }
1023+ }
1024+ result := socialRegressionBrowse(t, tenant, map[string]any{"limit": 10})
1025+ items := result["items"].([]socialItem)
1026+ if len(items) != 1 || items[0].ID != note.ID {
1027+ t.Fatalf("items = %#v", items)
1028+ }
1029+ if items[0].ReplyCount != 0 || len(items[0].Reactions) != 0 {
1030+ t.Fatalf("hidden activity folded into note: %#v", items[0])
1031+ }
1032+ if items[0].AuthorName == "leaked name" || items[0].AuthorPicture != "" {
1033+ t.Fatalf("hidden profile leaked: %#v", items[0])
1034+ }
1035+ tenant.policy.Reads = "members"
1036+ _, err := tenant.Execute(context.Background(), "", "browsesocial", []json.RawMessage{rawJSON(map[string]any{"limit": 10})})
1037+ if err == nil || !strings.Contains(err.Error(), "members-only") {
1038+ t.Fatalf("members-only social feed guest error = %v", err)
1039+ }
1040+}
1041+
1042+func TestSocialRegressionSearchCursorCrossesSparsePages(t *testing.T) {
1043+ _, tenant := testTenant(t)
1044+ secret := strings.Repeat("b", 63) + "c"
1045+ newest := signedEvent(t, secret, 1, 1000, nil, "needle newest")
1046+ rows := []event.Event{newest}
1047+ for at := int64(999); at >= 850; at-- {
1048+ rows = append(rows, signedEvent(t, secret, 1, at, nil, "filler"))
1049+ }
1050+ oldest := signedEvent(t, secret, 1, 1, nil, "needle oldest")
1051+ rows = append(rows, oldest)
1052+ saveSocialRegression(t, tenant, rows...)
1053+ first := socialRegressionBrowse(t, tenant, map[string]any{"q": "needle", "limit": 1})
1054+ items := first["items"].([]socialItem)
1055+ if len(items) != 1 || items[0].ID != newest.ID || first["next_cursor"] == "" {
1056+ t.Fatalf("first search page = %#v", first)
1057+ }
1058+ second := socialRegressionBrowse(t, tenant, map[string]any{"q": "needle", "limit": 1, "cursor": first["next_cursor"]})
1059+ items = second["items"].([]socialItem)
1060+ if len(items) != 1 || items[0].ID != oldest.ID {
1061+ t.Fatalf("second search page = %#v", second)
1062+ }
1063+}
1064+
1065+func TestSocialRegressionAddressRevisionKeepsCommentsAndReactions(t *testing.T) {
1066+ _, tenant := testTenant(t)
1067+ authorSecret := strings.Repeat("c", 63) + "d"
1068+ commenterSecret := strings.Repeat("e", 63) + "f"
1069+ old := signedEvent(t, authorSecret, 30023, 100, [][]string{{"d", "release"}, {"title", "Old"}}, "old")
1070+ current := signedEvent(t, authorSecret, 30023, 200, [][]string{{"d", "release"}, {"title", "Current"}}, "current")
1071+ address := "30023:" + current.PubKey + ":release"
1072+ comment := signedEvent(t, commenterSecret, 1111, 201, [][]string{{"A", address}, {"K", "30023"}, {"P", current.PubKey}, {"a", address}, {"k", "30023"}, {"p", current.PubKey}}, "comment survives revision")
1073+ reaction := signedEvent(t, commenterSecret, 7, 202, [][]string{{"a", address}, {"e", old.ID}}, "+")
1074+ saveSocialRegression(t, tenant, old, current, comment, reaction)
1075+ result := socialRegressionBrowse(t, tenant, map[string]any{"kind": "articles", "limit": 10})
1076+ items := result["items"].([]socialItem)
1077+ if len(items) != 1 || items[0].Address != address || items[0].Title != "Current" {
1078+ t.Fatalf("canonical article = %#v", items)
1079+ }
1080+ if items[0].ReplyCount != 1 || len(items[0].Reactions) != 1 || items[0].Reactions[0].Count != 1 {
1081+ t.Fatalf("address activity lost: %#v", items[0])
1082+ }
1083+}
1084+
1085+func TestSocialRegressionNIP10MentionIsNotReply(t *testing.T) {
1086+ _, tenant := testTenant(t)
1087+ secret := strings.Repeat("d", 63) + "e"
1088+ mentioned := signedEvent(t, secret, 1, 100, nil, "mentioned")
1089+ note := signedEvent(t, secret, 1, 101, [][]string{{"e", mentioned.ID, "", "mention", mentioned.PubKey}, {"q", mentioned.ID}, {"p", mentioned.PubKey}}, "mention only")
1090+ saveSocialRegression(t, tenant, mentioned, note)
1091+ result := socialRegressionBrowse(t, tenant, map[string]any{"limit": 10})
1092+ items := result["items"].([]socialItem)
1093+ if len(items) != 2 {
1094+ t.Fatalf("mention-only note omitted: %#v", items)
1095+ }
1096+ for _, item := range items {
1097+ if item.ID == note.ID && (item.ParentID != "" || item.RootID != note.ID) {
1098+ t.Fatalf("mention-only note treated as reply: %#v", item)
1099+ }
1100+ }
1101+}
1102diff --git a/internal/templates/templates.go b/internal/templates/templates.go
1103index 7459e7c..af96467 100644
1104--- a/internal/templates/templates.go
1105+++ b/internal/templates/templates.go
1106@@ -51,6 +51,7 @@ var templateCatalog = []Template{
1107 {Name: "media", Title: "Media", About: "A media host. Members upload files; the only events accepted are profiles and Blossom server lists. Anyone reads.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 10063}, Connections: []string{"files", "photos"}},
1108 {Name: "search", Title: "Search replica", About: "A read-only copy of another relay's notes, threads, comments, highlights, articles and wiki pages, refreshed every six hours. Anyone reads.", Source: "required", EveryHours: 6, Policy: map[string]any{"writes": "owner", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 1, 11, 1111, 9802, 30023, 30818}, Connections: []string{"notes"}},
1109 {Name: "articles", Title: "Articles", About: "Long-form articles and the profiles behind them, nothing else. Only you write, anyone reads. Give a source relay to mirror its articles daily.", Source: "optional", EveryHours: 24, Policy: map[string]any{"writes": "owner", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 30023}, Connections: []string{"notes"}},
1110+ {Name: "social", Title: "Social", About: "A chronological social feed for members' notes, comments, reactions and long-form articles. Members write; anyone reads.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true}, AllowKinds: []int{0, 1, 5, 6, 7, 1111, 30023}, Connections: []string{"notes", "blog"}},
1111 {Name: "dm", Title: "DM inbox", About: "An inbox for private messages. Anyone drops gift wraps, only members read them. Nothing else is accepted.", Policy: map[string]any{"writes": "open", "reads": "members", "directoryPublic": false}, AllowKinds: []int{0, 1059}, Connections: []string{"dm"}},
1112 {Name: "quiet", Title: "Quiet", About: "A small private relay with nothing that costs or announces. Members write and read; optional features are off.", Policy: map[string]any{"writes": "allowlist", "reads": "members", "directoryPublic": false, "features": map[string]any{"search": "off", "sync": false, "count": false, "discovery": false, "names": false, "files": false, "pages": false, "signer": false, "sites": false, "marmot": false, "grasp": false}}, Connections: []string{}},
1113 {Name: "site", Title: "Sites", About: "A static website host. Members publish manifests and files; the relay mirrors missing files into its own store.", Policy: map[string]any{"writes": "allowlist", "reads": "open", "directoryPublic": true, "features": map[string]any{"files": true, "sites": map[string]any{"enabled": true, "mirror": true}}}, Connections: []string{"notes"}},
1114diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go
1115index 47e6745..52a8e43 100644
1116--- a/internal/templates/templates_test.go
1117+++ b/internal/templates/templates_test.go
1118@@ -3,8 +3,8 @@ package templates
1119 import "testing"
1120
1121 func TestCatalogShapeAndTemplateApplication(t *testing.T) {
1122- if got := len(Names()); got != 15 {
1123- t.Fatalf("template count = %d, want 15", got)
1124+ if got := len(Names()); got != 16 {
1125+ t.Fatalf("template count = %d, want 16", got)
1126 }
1127 if got := len(Connections()); got != 12 {
1128 t.Fatalf("connection count = %d, want 12", got)
1129@@ -16,6 +16,18 @@ func TestCatalogShapeAndTemplateApplication(t *testing.T) {
1130 if p.Owner != "owner" || p.Writes != "owner" || !p.Delivery.Enabled || len(p.BlockedKinds) != 3 {
1131 t.Fatalf("outbox application: %#v", p)
1132 }
1133+ social, err := ApplyTemplate("social", "owner")
1134+ if err != nil {
1135+ t.Fatal(err)
1136+ }
1137+ if social.Owner != "owner" || social.Writes != "allowlist" || social.Reads != "open" {
1138+ t.Fatalf("social policy: %#v", social)
1139+ }
1140+ for _, kind := range []int{0, 1, 5, 6, 7, 1111, 30023} {
1141+ if !containsKind(social.AllowedKinds, kind) {
1142+ t.Fatalf("social does not allow kind %d: %#v", kind, social.AllowedKinds)
1143+ }
1144+ }
1145 if _, err := ApplyTemplate("missing", "owner"); err == nil {
1146 t.Fatal("missing template unexpectedly applied")
1147 }
1148@@ -25,3 +37,12 @@ func TestCatalogShapeAndTemplateApplication(t *testing.T) {
1149 }
1150 }
1151 }
1152+
1153+func containsKind(kinds []int, want int) bool {
1154+ for _, kind := range kinds {
1155+ if kind == want {
1156+ return true
1157+ }
1158+ }
1159+ return false
1160+}
1161diff --git a/internal/webui/components.js b/internal/webui/components.js
1162index 7ee426d..8d0c27f 100644
1163--- a/internal/webui/components.js
1164+++ b/internal/webui/components.js
1165@@ -991,6 +991,203 @@
1166 // until the button is pressed: permission is requested then, and the
1167 // device subscription is registered with a signed request.
1168 const pushCategories = [["messages", "private messages"], ["replies", "replies to you"], ["mentions", "mentions"], ["approvals", "requests for a decision"], ["relay", "relay notices"]];
1169+ // Social follows NIP-10 notes, NIP-23 articles and NIP-22 comments.
1170+ const socialValue = (form, name) => String(form?.elements?.[name]?.value || "").trim();
1171+ const socialDraftFields = ["title", "summary", "cover", "content", "tags", "media", "alt"];
1172+ const socialDraftKey = node => {
1173+ const actor = node.getAttribute("actor");
1174+ if (!isHex64(actor)) return "";
1175+ return "tiny.social.draft." + JSON.stringify([location.origin, tiny.root || "", actor, node.getAttribute("mode") || "note", node.getAttribute("address") || "", node.getAttribute("target") || ""]);
1176+ };
1177+ const socialHTTPURL = value => {
1178+ if (!value) return "";
1179+ let url; try { url = new URL(value); } catch { throw Error("Enter a valid HTTP or HTTPS URL."); }
1180+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw Error("Media URLs must use HTTP or HTTPS without credentials.");
1181+ return url.href;
1182+ };
1183+ const socialRelay = node => {
1184+ const value = node.getAttribute("relay") || "";
1185+ return /^wss?:\/\//.test(value) ? value : "";
1186+ };
1187+ const socialSend = async event => {
1188+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
1189+ let result = {};
1190+ try { result = await response.json(); } catch {}
1191+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the post.");
1192+ return event;
1193+ };
1194+ class SocialCompose extends FormElement {
1195+ connectedCallback() {
1196+ if (this.form) return;
1197+ super.connectedCallback();
1198+ this.restoreDraft();
1199+ this.form.addEventListener("input", () => this.saveDraft());
1200+ this.querySelector("[data-preview]")?.addEventListener("click", () => this.preview().catch(error => this.report(error.message, true)));
1201+ }
1202+ saveDraft() {
1203+ const key = socialDraftKey(this);
1204+ if (!key) return;
1205+ try {
1206+ const values = Object.fromEntries(socialDraftFields.map(name => [name, this.form.elements[name]?.value || ""]));
1207+ if (!Object.values(values).some(Boolean)) { localStorage.removeItem(key); return; }
1208+ localStorage.setItem(key, JSON.stringify({values, articleID: this.articleID || "", publishedAt: this.publishedAt || 0}));
1209+ } catch {}
1210+ }
1211+ restoreDraft() {
1212+ const key = socialDraftKey(this);
1213+ if (!key) return;
1214+ try {
1215+ const draft = JSON.parse(localStorage.getItem(key) || "null");
1216+ if (!draft || typeof draft.values !== "object") return;
1217+ for (const name of socialDraftFields) {
1218+ const input = this.form.elements[name];
1219+ if (input && !input.value && typeof draft.values[name] === "string") input.value = draft.values[name];
1220+ }
1221+ if (typeof draft.articleID === "string" && /^[a-z0-9-]{1,100}$/.test(draft.articleID)) this.articleID = draft.articleID;
1222+ if (Number.isSafeInteger(draft.publishedAt) && draft.publishedAt > 0) this.publishedAt = draft.publishedAt;
1223+ } catch {}
1224+ }
1225+ clearDraft() { try { const key = socialDraftKey(this); if (key) localStorage.removeItem(key); } catch {} }
1226+ kind() {
1227+ const mode = this.getAttribute("mode") || "note";
1228+ if (mode === "article") return 30023;
1229+ if (mode === "note") return 1;
1230+ if (mode !== "comment") throw Error("Choose a valid post format.");
1231+ const parent = this.getAttribute("target-kind") || "1";
1232+ if (parent === "1") return 1;
1233+ if (parent === "30023" || parent === "1111") return 1111;
1234+ throw Error("This post does not support replies here.");
1235+ }
1236+ replyTags(kind) {
1237+ const target = this.getAttribute("target") || "", author = this.getAttribute("target-pubkey") || "";
1238+ const root = this.getAttribute("root") || target, rootAuthor = this.getAttribute("root-pubkey") || (root === target ? author : "");
1239+ const hint = socialRelay(this);
1240+ if (![target, author, root].every(isHex64)) throw Error("The reply address is incomplete. Reload the conversation.");
1241+ if (kind === 1) {
1242+ const tags = [["e", root, hint, "root", ...(isHex64(rootAuthor) ? [rootAuthor] : [])]];
1243+ if (target !== root) tags.push(["e", target, hint, "reply", author]);
1244+ const participants = new Set([...(isHex64(rootAuthor) ? [rootAuthor] : []), author]);
1245+ try { for (const tag of JSON.parse(this.getAttribute("target-tags") || "[]")) if (Array.isArray(tag) && tag[0] === "p" && isHex64(tag[1])) participants.add(tag[1]); } catch {}
1246+ for (const pubkey of participants) tags.push(["p", pubkey, hint]);
1247+ return tags;
1248+ }
1249+ const address = this.getAttribute("address") || "";
1250+ const parentKind = this.getAttribute("target-kind") || "30023";
1251+ if (!isHex64(rootAuthor) || !address.startsWith("30023:" + rootAuthor + ":") || !["30023", "1111"].includes(parentKind)) throw Error("The article address is incomplete. Reload the conversation.");
1252+ const tags = [["A", address, hint], ["K", "30023"], ["P", rootAuthor, hint]];
1253+ if (parentKind === "30023") tags.push(["a", address, hint]);
1254+ tags.push(["e", target, hint, author], ["k", parentKind], ["p", author, hint]);
1255+ return tags;
1256+ }
1257+ tags(form, kind) {
1258+ const tags = this.getAttribute("mode") === "comment" ? this.replyTags(kind) : [];
1259+ if (kind === 30023) {
1260+ if (!socialValue(form, "title")) throw Error("Give your article a title.");
1261+ this.articleID ||= crypto.randomUUID();
1262+ this.publishedAt ||= unixNow();
1263+ tags.push(["d", this.articleID], ["title", socialValue(form, "title")], ["published_at", String(this.publishedAt)]);
1264+ if (socialValue(form, "summary")) tags.push(["summary", socialValue(form, "summary")]);
1265+ const cover = socialHTTPURL(socialValue(form, "cover"));
1266+ if (cover) tags.push(["image", cover]);
1267+ }
1268+ const topics = new Set(socialValue(form, "tags").split(",").map(value => value.trim().replace(/^#/, "").toLowerCase()).filter(Boolean));
1269+ for (const topic of topics) tags.push(["t", topic]);
1270+ return tags;
1271+ }
1272+ async attachments(form) {
1273+ const files = [...(form.elements["media-file"]?.files || [])];
1274+ if (files.length > 10 || files.some(file => file.size > 256 * 1024 * 1024)) throw Error("Choose up to 10 attachments, each at most 256 MiB.");
1275+ if (files.some(file => !/^(image|video|audio)\//.test(file.type))) throw Error("Choose an image, video or audio file.");
1276+ this.mediaUploads ||= new WeakMap();
1277+ const items = [];
1278+ for (const file of files) {
1279+ let item = this.mediaUploads.get(file);
1280+ if (!item) {
1281+ this.report("Uploading " + file.name + "…");
1282+ const bytes = new Uint8Array(await file.arrayBuffer()), hash = await tiny.sha256hex(bytes);
1283+ const path = "/upload?filename=" + encodeURIComponent(file.name) + "&purpose=file";
1284+ const response = await tiny.signedFetch(path, "PUT", bytes, {contentType: file.type});
1285+ const descriptor = await response.json();
1286+ if (!response.ok || descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw Error("The attachment could not be verified. Try again.");
1287+ item = {url: socialHTTPURL(descriptor.url), type: file.type, hash, size: file.size, filename: file.name};
1288+ if (!item.url) throw Error("The attachment URL is missing.");
1289+ this.mediaUploads.set(file, item);
1290+ }
1291+ items.push({...item, alt: socialValue(form, "alt") || file.name});
1292+ }
1293+ const media = socialHTTPURL(socialValue(form, "media"));
1294+ if (media && !items.some(item => item.url === media)) items.push({url: media, alt: socialValue(form, "alt")});
1295+ return items;
1296+ }
1297+ async preview() {
1298+ const output = this.querySelector("[data-preview-output]");
1299+ if (!output) return;
1300+ const response = await tiny.signedFetch("/social/preview", "POST", JSON.stringify({content: this.form.elements.content?.value || "", kind: this.kind()}));
1301+ if (!response.ok) throw Error("Preview is unavailable. Your draft is still saved.");
1302+ const result = await response.json();
1303+ if (typeof result.html !== "string") throw Error("Preview is unavailable.");
1304+ output.innerHTML = result.html;
1305+ output.hidden = false;
1306+ }
1307+ async submit(form) {
1308+ if (this.sending) return;
1309+ this.sending = true;
1310+ try {
1311+ const kind = this.kind(), content = form.elements.content?.value || "";
1312+ if (!content.trim() && !(form.elements["media-file"]?.files?.length) && !socialValue(form, "media")) throw Error("Write something or attach media first.");
1313+ const tags = this.tags(form, kind);
1314+ this.saveDraft();
1315+ const attachments = await this.attachments(form);
1316+ const urls = attachments.map(item => item.url).filter(url => !content.includes(url));
1317+ const body = [content, ...urls].filter(Boolean).join("\n\n");
1318+ for (const item of attachments) tags.push(["imeta", "url " + item.url, ...(item.type ? ["m " + item.type] : []), ...(item.hash ? ["x " + item.hash, "size " + item.size] : []), ...(item.alt ? ["alt " + item.alt] : [])]);
1319+ const fingerprint = JSON.stringify({kind, tags, content: body});
1320+ if (this.prepared?.fingerprint !== fingerprint) this.prepared = {fingerprint, unsigned: {kind, created_at: unixNow(), tags, content: body}};
1321+ this.report("Signing and publishing…");
1322+ this.prepared.event ||= await tiny.signing.signEvent(this.prepared.unsigned);
1323+ const event = await socialSend(this.prepared.event);
1324+ this.prepared = null;
1325+ this.clearDraft();
1326+ this.articleID = ""; this.publishedAt = 0;
1327+ form.reset?.();
1328+ this.report("Published.");
1329+ try { await tiny.navigate?.(this.getAttribute("refresh") || location.href); } catch { this.report("Published. Refresh to see your post."); }
1330+ return event;
1331+ } finally { this.sending = false; }
1332+ }
1333+ }
1334+ class SocialReaction extends HTMLElement {
1335+ connectedCallback() {
1336+ if (this.bound) return;
1337+ this.bound = true;
1338+ const button = this.querySelector("button");
1339+ if (!button) return;
1340+ button.addEventListener("click", () => this.react(button));
1341+ }
1342+ async react(button) {
1343+ if (this.busy || button.getAttribute("aria-pressed") === "true") return;
1344+ const target = this.getAttribute("event"), author = this.getAttribute("pubkey"), kind = this.getAttribute("kind"), hint = socialRelay(this);
1345+ if (!isHex64(target) || !isHex64(author)) return;
1346+ this.busy = true; button.disabled = true;
1347+ try {
1348+ const tags = [["e", target, hint, author], ["p", author, hint]];
1349+ if (/^\d+$/.test(kind || "")) tags.push(["k", kind]);
1350+ if (kind === "30023" && this.getAttribute("address")) tags.push(["a", this.getAttribute("address"), hint, author]);
1351+ const content = this.getAttribute("content") || "+";
1352+ this.prepared ||= await tiny.signing.signEvent({kind: 7, created_at: unixNow(), tags, content});
1353+ await socialSend(this.prepared);
1354+ button.setAttribute("aria-pressed", "true");
1355+ try { await tiny.navigate?.(location.href); } catch { button.textContent = content === "+" ? "Liked" : content; }
1356+ } catch (error) {
1357+ button.disabled = false;
1358+ let output = this.querySelector("output");
1359+ if (!output) { output = el("output"); output.setAttribute("role", "status"); this.append(output); }
1360+ output.textContent = "Error: " + error.message;
1361+ } finally { this.busy = false; }
1362+ }
1363+ }
1364+
1365+ // Social end.
1366 class PushToggle extends HTMLElement {
1367 connectedCallback() {
1368 if (this.bound) return;
1369@@ -1434,6 +1631,8 @@
1370 customElements.define("agent-grant", AgentGrant);
1371 customElements.define("profile-form", ProfileForm);
1372 customElements.define("nostr-react", NostrReact);
1373+ customElements.define("social-compose", SocialCompose);
1374+ customElements.define("social-reaction", SocialReaction);
1375 customElements.define("approval-item", ApprovalItem);
1376 customElements.define("wiki-compose", WikiCompose);
1377 customElements.define("push-toggle", PushToggle);
1378@@ -1468,7 +1667,7 @@
1379 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
1380 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
1381 };
1382- const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki)\/.+|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
1383+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
1384 let navigationSerial = 0, activeAbort;
1385 const streams = new Set();
1386 const closeStreams = () => {
1387@@ -1483,7 +1682,7 @@
1388 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
1389 window.addEventListener("pagehide", closeStreams);
1390 const repairComponents = () => {
1391- document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {
1392+ document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,social-compose,social-reaction,wiki-compose,room-compose,room-create,room-action").forEach(node => {
1393 if (node.form?.isConnected) return;
1394 node.form = null;
1395 node.output = null;
1396diff --git a/internal/webui/page.html b/internal/webui/page.html
1397index 261d277..1cf6b9a 100644
1398--- a/internal/webui/page.html
1399+++ b/internal/webui/page.html
1400@@ -33,7 +33,8 @@ then feature modules are loaded for the current page. */}}
1401 {{else if eq .Tab "inbox"}}{{template "inbox" .}}
1402 {{else if eq .Tab "outbox"}}{{template "outbox" .}}
1403 {{else if eq .Tab "search"}}{{template "search" .}}
1404-{{else if eq .Tab "articles"}}{{template "articles" .}}
1405+{{else if eq .Tab "social"}}{{template "social" .}}
1406+{{else if eq .Tab "social-thread"}}{{template "social-thread" .}}
1407 {{else if eq .Tab "private"}}{{template "private" .}}
1408 {{else if eq .Tab "chat"}}{{template "chat" .}}
1409 {{else if eq .Tab "media"}}{{template "media" .}}
1410@@ -110,6 +111,7 @@ then feature modules are loaded for the current page. */}}
1411 {{else if eq .Tab "agents"}}{{template "panel-agents" .}}
1412 {{else if eq $kind "manage"}}{{template "panel-manage" .}}
1413 {{else if eq $kind "rooms"}}{{template "panel-rooms" .}}
1414+{{else if or (eq .Tab "social") (eq .Tab "social-thread")}}{{template "panel-social" .}}
1415 {{else if or (eq .Tab "inbox") (eq .Tab "outbox") (eq .Tab "search") (eq .Tab "articles")}}{{template "panel-feed" .}}
1416 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}
1417 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}
1418diff --git a/internal/webui/public_pages.go b/internal/webui/public_pages.go
1419index 804d484..c7a00ae 100644
1420--- a/internal/webui/public_pages.go
1421+++ b/internal/webui/public_pages.go
1422@@ -179,7 +179,7 @@ func (a *App) pageFeatureEnabled(path string) bool {
1423 if a.backend.Policy().Features.Pages {
1424 return true
1425 }
1426- return !(path == "/articles.json" || path == "/feed" || path == "/feed.xml" || path == "/articles" || strings.HasPrefix(path, "/e/") || strings.HasPrefix(path, "/a/"))
1427+ return !(path == "/social" || path == "/social.json" || path == "/social.xml" || strings.HasPrefix(path, "/social/") || path == "/articles.json" || path == "/feed" || path == "/feed.xml" || path == "/articles" || strings.HasPrefix(path, "/e/") || strings.HasPrefix(path, "/a/"))
1428 }
1429
1430 func (a *App) feed(writer http.ResponseWriter, request *http.Request) {
1431@@ -188,14 +188,20 @@ func (a *App) feed(writer http.ResponseWriter, request *http.Request) {
1432 http.Error(writer, err.Error(), publicErrorStatus(err))
1433 return
1434 }
1435+ entries := make([]map[string]any, 0, len(items))
1436+ for _, value := range items {
1437+ if item, ok := articleItem(value, a.backend.URL()); ok {
1438+ entries = append(entries, item)
1439+ }
1440+ }
1441+ a.writeAtom(writer, entries)
1442+}
1443+
1444+func (a *App) writeAtom(writer http.ResponseWriter, items []map[string]any) {
1445 writer.Header().Set("content-type", "application/atom+xml; charset=utf-8")
1446 var out strings.Builder
1447 fmt.Fprintf(&out, `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>%s</title><link href="%s"/>`, template.HTMLEscapeString(a.backend.Slug()), template.HTMLEscapeString(a.backend.URL()))
1448- for _, value := range items {
1449- item, ok := articleItem(value, a.backend.URL())
1450- if !ok {
1451- continue
1452- }
1453+ for _, item := range items {
1454 fmt.Fprintf(&out, `<entry><id>%s</id><title>%s</title><link href="%s"/><content type="text">%s</content></entry>`, template.HTMLEscapeString(fmt.Sprint(item["id"])), template.HTMLEscapeString(fmt.Sprint(item["title"])), template.HTMLEscapeString(fmt.Sprint(item["url"])), template.HTMLEscapeString(fmt.Sprint(item["content_text"])))
1455 }
1456 out.WriteString(`</feed>`)
1457diff --git a/internal/webui/public_pages_test.go b/internal/webui/public_pages_test.go
1458index 1a28f76..583a03e 100644
1459--- a/internal/webui/public_pages_test.go
1460+++ b/internal/webui/public_pages_test.go
1461@@ -62,7 +62,7 @@ func TestPublicSearchAndInboxRenderBackendResults(t *testing.T) {
1462 if err != nil {
1463 t.Fatal(err)
1464 }
1465- for _, path := range []string{"/search?q=visible", "/inbox", "/outbox", "/articles"} {
1466+ for _, path := range []string{"/search?q=visible", "/inbox", "/outbox"} {
1467 recorder := httptest.NewRecorder()
1468 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
1469 body := recorder.Body.String()
1470diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
1471index 61d0cd1..abe2b53 100644
1472--- a/internal/webui/shell_test.go
1473+++ b/internal/webui/shell_test.go
1474@@ -73,7 +73,7 @@ func TestShellNavigationIsProgressiveAndAccessible(t *testing.T) {
1475
1476 // The menu is part of the shell, so each viewer and rail context retains
1477 // one native control and one shared navigation container.
1478- for _, path := range []string{"/search", "/articles", "/files", "/repo?owner=alice&repo=notes&view=history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {
1479+ for _, path := range []string{"/search", "/social", "/files", "/repo?owner=alice&repo=notes&view=history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {
1480 recorder := httptest.NewRecorder()
1481 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
1482 if recorder.Code != http.StatusOK {
1483diff --git a/internal/webui/social.html b/internal/webui/social.html
1484new file mode 100644
1485index 0000000..568a20a
1486--- /dev/null
1487+++ b/internal/webui/social.html
1488@@ -0,0 +1,69 @@
1489+{{define "social"}}<section id="social">
1490+<header id="social-head"><h1>Social</h1><p>Notes, stories and conversations, newest first.</p></header>
1491+{{if .Error}}{{template "alert" .Error}}{{else}}
1492+{{if .Actor}}{{template "socialComposer" .}}{{else}}<p data-social-welcome><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to join the conversation.</p>{{end}}
1493+<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>
1494+{{if .Feed}}<ol id="social-feed">{{range .Feed}}{{template "socialPost" (socialContext . $)}}{{end}}</ol>
1495+{{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}}
1496+{{with index (asMap .Event) "next_cursor"}}<p id="social-pages"><a href="{{socialQuery $.Query "cursor" (str .)}}">Older posts →</a></p>{{end}}
1497+{{end}}<noscript><p>Reading works without JavaScript. Publishing needs JavaScript and a connected signer.</p></noscript>
1498+</section>{{end}}
1499+
1500+{{define "social-thread"}}{{$post := socialContext (index (asMap .Event) "post") .}}<section id="social-thread">
1501+<p data-social-back><a href="/social">← Social</a></p>
1502+{{if .Error}}{{template "alert" .Error}}{{else}}
1503+{{if index $post "parent_url"}}<p data-thread-context><a href="{{index $post "parent_url"}}">↑ Parent post</a>{{if ne (index $post "parent_url") (index $post "root_url")}}{{with index $post "root_url"}} <a href="{{.}}">View full conversation</a>{{end}}{{end}}</p>{{end}}
1504+<ol id="social-thread-post">{{template "socialPost" $post}}</ol>
1505+<div id="social-comments"><h2>Conversation{{with index $post "reply_count"}} <small>{{.}}</small>{{end}}</h2>
1506+{{if .Actor}}{{template "socialCommentComposer" $post}}{{else}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>{{end}}
1507+{{with index (asMap .Event) "comments"}}<ol>{{range .}}{{template "socialComment" (socialContext . $)}}{{end}}</ol>{{else}}<p data-social-empty-replies>No replies yet. Start the conversation.</p>{{end}}
1508+{{with index (asMap .Event) "next_cursor"}}<p><a href="{{socialThreadPage $.Path $.Query (str .)}}">Older replies →</a></p>{{end}}
1509+</div>{{end}}</section>{{end}}
1510+
1511+{{define "socialPost"}}{{$p := asMap .}}<li data-social-post data-kind="{{str (index $p "kind")}}">
1512+{{template "socialByline" $p}}
1513+{{if eq (str (index $p "kind")) "30023"}}
1514+<article data-post-body="article">
1515+{{if index $p "detail"}}<h1>{{or (str (index $p "title")) "Untitled article"}}</h1>{{else}}<h2><a href="{{socialURL $p}}">{{or (str (index $p "title")) "Untitled article"}}</a></h2>{{end}}
1516+{{with index $p "summary"}}<p data-post-summary>{{str .}}</p>{{else}}{{if not (index $p "detail")}}<p data-post-summary>{{socialPreview $p}}</p>{{end}}{{end}}
1517+{{with socialMediaURL (str (index $p "image"))}}<a href="{{socialURL $p}}" data-post-media><img src="{{.}}" alt="{{or (str (index $p "title")) "Article cover"}}" loading="lazy" referrerpolicy="no-referrer"></a>{{end}}
1518+{{if index $p "detail"}}<div data-article-content>{{socialBody $p}}</div>{{end}}
1519+{{with index $p "read_minutes"}}<small data-read-time>{{.}} min read{{if not (index $p "detail")}} <a href="{{socialURL $p}}">Read article →</a>{{end}}</small>{{end}}
1520+</article>
1521+{{else}}<article data-post-body="note">{{socialBody $p}}</article>{{end}}
1522+{{template "socialActions" $p}}
1523+</li>{{end}}
1524+
1525+{{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}}<span aria-hidden="true">@</span>{{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}}
1526+
1527+{{define "socialActions"}}{{$p := asMap .}}<footer data-social-actions>
1528+<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>
1529+{{if index $p "viewer"}}<social-reaction actor="{{index $p "viewer"}}" event="{{index $p "id"}}" pubkey="{{index $p "pubkey"}}" kind="{{index $p "kind"}}" address="{{index $p "address"}}" relay="{{index $p "relay"}}" content="+"><button type="button" aria-label="Like this post" aria-pressed="{{socialReacted $p "+"}}"{{if socialReacted $p "+"}} disabled{{end}}>♥ {{socialReactionCount $p "+"}}</button></social-reaction>{{else}}<span aria-label="Likes">♥ {{socialReactionCount $p "+"}}</span>{{end}}
1530+{{range index $p "reactions"}}{{$r := asMap .}}{{if ne (str (index $r "content")) "+"}}<span title="Reaction">{{with socialMediaURL (str (index $r "image"))}}<img data-reaction-emoji src="{{.}}" alt="{{index $r "content"}}" width="20" height="20" loading="lazy" referrerpolicy="no-referrer">{{else}}{{str (index $r "content")}}{{end}} {{index $r "count"}}</span>{{end}}{{end}}
1531+{{if index $p "viewer"}}<details data-social-react><summary>React</summary>{{range strs "👍" "❤️" "😂" "🎉" "🤔"}}<social-reaction actor="{{index $p "viewer"}}" event="{{index $p "id"}}" pubkey="{{index $p "pubkey"}}" kind="{{index $p "kind"}}" address="{{index $p "address"}}" relay="{{index $p "relay"}}" content="{{.}}"><button type="button" aria-label="React {{.}}" aria-pressed="{{socialReacted $p .}}"{{if socialReacted $p .}} disabled{{end}}>{{.}}</button></social-reaction>{{end}}</details>{{end}}
1532+{{if index $p "detail"}}<share-link url="{{socialURL $p}}"></share-link>{{else}}<a href="{{socialURL $p}}">Open</a>{{end}}
1533+</footer>{{end}}
1534+
1535+{{define "socialComposer"}}{{$article := eq (.Query.Get "compose") "article"}}
1536+<nav id="social-write" aria-label="Write"><a href="{{socialQuery .Query "compose" ""}}"{{if not $article}} aria-current="page"{{end}}>Write a note</a><a href="{{socialQuery .Query "compose" "article"}}"{{if $article}} aria-current="page"{{end}}>Write an article</a></nav>
1537+<social-compose mode="{{if $article}}article{{else}}note{{end}}" actor="{{.Actor}}" relay="{{wsURL .URL}}" refresh="/social">
1538+{{if $article}}<label>Title <input name="title" required maxlength="200" placeholder="Give your story a title"></label><label>Subtitle <input name="summary" maxlength="500" placeholder="A short introduction"></label>{{end}}
1539+<label>{{if $article}}Article{{else}}Note{{end}} <textarea name="content" rows="{{if $article}}14{{else}}3{{end}}" maxlength="65000" placeholder="{{if $article}}Tell your story. Markdown is supported.{{else}}What’s on your mind?{{end}}"></textarea></label>
1540+{{if $article}}<label>Cover image URL <input name="cover" type="url" placeholder="https://"></label>{{end}}
1541+{{template "socialMediaFields" .}}
1542+<details data-post-options><summary>Topics</summary><label>Tags <input name="tags" placeholder="nature, design, notes"></label></details>
1543+<p data-compose-actions><button type="submit">{{if $article}}Publish article{{else}}Post note{{end}}</button> <button type="button" data-preview>Preview</button> <small>Draft saved in this browser.</small></p><div data-preview-output hidden></div>
1544+</social-compose>{{end}}
1545+
1546+{{define "socialMediaFields"}}<details data-post-options><summary>Attach media</summary><label>Images, video or audio <input type="file" name="media-file" accept="image/*,video/*,audio/*" multiple></label><label>Or paste a media URL <input name="media" type="url" placeholder="https://"></label><label>Description <input name="alt" maxlength="1000" placeholder="Describe the media for readers"></label><small>Attachments are published with your post.</small></details>{{end}}
1547+
1548+{{define "socialCommentComposer"}}{{$p := asMap .}}<social-compose id="reply" mode="comment" actor="{{index $p "viewer"}}" relay="{{index $p "relay"}}" target="{{index $p "id"}}" target-kind="{{index $p "kind"}}" target-pubkey="{{index $p "pubkey"}}" target-tags="{{json (index $p "tags")}}" address="{{or (str (index $p "root_address")) (str (index $p "address"))}}" root="{{or (str (index $p "root_id")) (str (index $p "id"))}}" root-pubkey="{{str (index $p "root_author")}}" root-kind="{{or (str (index $p "root_kind")) (str (index $p "kind"))}}" refresh="{{socialURL $p}}">
1549+<label>Reply <textarea name="content" rows="3" maxlength="20000" placeholder="Add to the conversation"></textarea></label>{{template "socialMediaFields" .}}<p data-compose-actions><button type="submit">Post reply</button> <button type="button" data-preview>Preview</button></p><div data-preview-output hidden></div>
1550+</social-compose>{{end}}
1551+
1552+{{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}}
1553+
1554+{{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>
1555+{{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}}
1556+<section><h4>Follow the feed</h4><p><a href="/social.xml">Atom</a> | <a href="/social.json">JSON Feed</a></p></section>
1557+{{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}}
1558diff --git a/internal/webui/social_content.go b/internal/webui/social_content.go
1559new file mode 100644
1560index 0000000..6a8f008
1561--- /dev/null
1562+++ b/internal/webui/social_content.go
1563@@ -0,0 +1,285 @@
1564+package webui
1565+
1566+import (
1567+ "crypto/rand"
1568+ "crypto/sha256"
1569+ "encoding/hex"
1570+ "html"
1571+ "html/template"
1572+ "net/url"
1573+ "path"
1574+ "regexp"
1575+ "strconv"
1576+ "strings"
1577+
1578+ "github.com/FelineStateMachine/tinyrelay/internal/views"
1579+)
1580+
1581+// socialBody renders user supplied social content with the same small
1582+// Markdown grammar used by chat. Media is deliberately limited to ordinary
1583+// HTTP(S) URLs and native browser elements.
1584+func (a *App) socialBody(value any) template.HTML {
1585+ return socialBodyWith(value, a.blocks())
1586+}
1587+
1588+func socialBody(value any) template.HTML {
1589+ return socialBodyWith(value, nil)
1590+}
1591+
1592+func socialBodyWith(value any, blocks blockRenderer) template.HTML {
1593+ content := plainString(value)
1594+ var attachments []roomAttachment
1595+ breaks := true
1596+ if m := valueMap(value); m != nil {
1597+ content = plainString(m["content"])
1598+ attachments = socialAttachments(m)
1599+ kind := plainString(m["kind"])
1600+ breaks = kind != "30023"
1601+ }
1602+ nonce := rand.Text()
1603+ tokenFor := func(raw string) string { return nonce + socialMediaToken(raw) }
1604+ for _, item := range attachments {
1605+ if !breaks {
1606+ content = replaceSocialMedia(content, item.URL, tokenFor(item.URL))
1607+ }
1608+ }
1609+ content = replaceStandaloneSocialMedia(content, attachments, tokenFor)
1610+ var rendered string
1611+ if breaks && valueMap(value) != nil {
1612+ // NIP-01 notes and NIP-22 comments are plaintext. Preserve their
1613+ // newlines and links without interpreting author text as Markdown.
1614+ raw := html.EscapeString(content)
1615+ raw = strings.ReplaceAll(raw, "\n", "<br>")
1616+ rendered = autolinkOutsideTags(raw)
1617+ } else if breaks {
1618+ rendered = string(renderChatMarkdownWith(content, blocks))
1619+ } else {
1620+ rendered = string(renderMarkdownWith(content, blocks))
1621+ }
1622+ for _, item := range attachments {
1623+ token := html.EscapeString(tokenFor(item.URL))
1624+ rendered = strings.ReplaceAll(rendered, "<p>"+token+"</p>", socialMediaFigure(item))
1625+ rendered = strings.ReplaceAll(rendered, token, socialMediaInline(item))
1626+ }
1627+ return template.HTML(socialNostrLinks(socialMediaDimensions(rendered, value)))
1628+}
1629+
1630+func socialMediaDimensions(rendered string, value any) string {
1631+ for _, tag := range roomTags(value) {
1632+ if len(tag) < 2 || tag[0] != "imeta" {
1633+ continue
1634+ }
1635+ var raw, dimensions string
1636+ for _, field := range tag[1:] {
1637+ if strings.HasPrefix(field, "url ") {
1638+ raw = socialMediaURL(strings.TrimPrefix(field, "url "))
1639+ }
1640+ if strings.HasPrefix(field, "dim ") {
1641+ dimensions = strings.TrimPrefix(field, "dim ")
1642+ }
1643+ }
1644+ widthText, heightText, ok := strings.Cut(dimensions, "x")
1645+ if raw == "" || !ok {
1646+ continue
1647+ }
1648+ width, widthErr := strconv.ParseFloat(widthText, 64)
1649+ height, heightErr := strconv.ParseFloat(heightText, 64)
1650+ if widthErr != nil || heightErr != nil || !(width >= 1 && width <= 32000 && height >= 1 && height <= 32000) {
1651+ continue
1652+ }
1653+ prefix := `<img src="` + html.EscapeString(raw) + `" `
1654+ attributes := `width="` + strconv.Itoa(int(width)) + `" height="` + strconv.Itoa(int(height)) + `" `
1655+ rendered = strings.ReplaceAll(rendered, prefix, prefix+attributes)
1656+ }
1657+ return rendered
1658+}
1659+
1660+// socialPreview returns a compact plain-text excerpt suitable for feed cards.
1661+func socialPreview(value any) string {
1662+ text := plainString(value)
1663+ if m := valueMap(value); m != nil {
1664+ text = plainString(m["content"])
1665+ }
1666+ text = regexp.MustCompile("(?s)```.*?```|`([^`]+)`|!\\[([^\\]]*)\\]\\([^)]*\\)").ReplaceAllString(text, "$1$2")
1667+ text = strings.Join(strings.Fields(text), " ")
1668+ if len([]rune(text)) > 280 {
1669+ text = string([]rune(text)[:277]) + "..."
1670+ }
1671+ return text
1672+}
1673+
1674+// socialMediaURL accepts only absolute HTTP(S) URLs without userinfo.
1675+func socialMediaURL(raw string) string {
1676+ raw = strings.TrimSpace(raw)
1677+ u, err := url.Parse(raw)
1678+ if err != nil || u.User != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") {
1679+ return ""
1680+ }
1681+ return u.String()
1682+}
1683+
1684+var socialImage = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)\s]+)\)`)
1685+
1686+func socialMediaToken(raw string) string {
1687+ digest := sha256.Sum256([]byte(raw))
1688+ return "social-media-" + hex.EncodeToString(digest[:])
1689+}
1690+
1691+func replaceSocialMedia(content, raw, replacement string) string {
1692+ return replaceSocialOutsideCode(content, func(part string) string {
1693+ return roomAttachmentReference.ReplaceAllStringFunc(part, func(match string) string {
1694+ parts := roomAttachmentReference.FindStringSubmatch(match)
1695+ if len(parts) == 4 && parts[2] == raw {
1696+ return replacement
1697+ }
1698+ return match
1699+ })
1700+ })
1701+}
1702+
1703+func replaceSocialOutsideCode(content string, replace func(string) string) string {
1704+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
1705+ marker := ""
1706+ for i, line := range lines {
1707+ if marker != "" {
1708+ if views.ClosesFence(line, marker) {
1709+ marker = ""
1710+ }
1711+ continue
1712+ }
1713+ if next, _, ok := views.OpenFence(line); ok {
1714+ marker = next
1715+ continue
1716+ }
1717+ lines[i] = replaceSocialInlineCode(line, replace)
1718+ }
1719+ return strings.Join(lines, "\n")
1720+}
1721+
1722+func replaceSocialInlineCode(line string, replace func(string) string) string {
1723+ var out strings.Builder
1724+ for len(line) > 0 {
1725+ start := strings.IndexByte(line, '`')
1726+ if start < 0 {
1727+ out.WriteString(replace(line))
1728+ break
1729+ }
1730+ out.WriteString(replace(line[:start]))
1731+ run := 1
1732+ for run < len(line)-start && line[start+run] == '`' {
1733+ run++
1734+ }
1735+ end := strings.Index(line[start+run:], strings.Repeat("`", run))
1736+ if end < 0 {
1737+ out.WriteString(line[start:])
1738+ break
1739+ }
1740+ end += start + run
1741+ out.WriteString(line[start : end+run])
1742+ line = line[end+run:]
1743+ }
1744+ return out.String()
1745+}
1746+
1747+func replaceStandaloneSocialMedia(content string, items []roomAttachment, tokenFor func(string) string) string {
1748+ known := make(map[string]bool, len(items))
1749+ for _, item := range items {
1750+ known[item.URL] = true
1751+ }
1752+ return replaceSocialOutsideCode(content, func(line string) string {
1753+ trimmed := strings.TrimSpace(line)
1754+ if raw := socialMediaURL(strings.TrimRight(trimmed, ".,;:!?")); raw != "" && raw == trimmed && known[raw] {
1755+ return tokenFor(raw)
1756+ }
1757+ return line
1758+ })
1759+}
1760+
1761+func socialMediaFigure(item roomAttachment) string {
1762+ raw := socialMediaURL(item.URL)
1763+ if raw == "" {
1764+ return ""
1765+ }
1766+ href := template.HTMLEscapeString(raw)
1767+ alt := template.HTMLEscapeString(roomAttachmentLabel(item))
1768+ if alt == "" {
1769+ alt = "Media"
1770+ }
1771+ var media string
1772+ mime := strings.ToLower(item.MIME)
1773+ if strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" {
1774+ media = `<img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer">`
1775+ } else if strings.HasPrefix(mime, "video/") {
1776+ media = `<video controls preload="metadata" playsinline referrerpolicy="no-referrer" src="` + href + `"></video>`
1777+ } else if strings.HasPrefix(mime, "audio/") {
1778+ media = `<audio controls preload="metadata" src="` + href + `"></audio>`
1779+ } else {
1780+ return `<a data-social-media href="` + href + `" rel="noopener noreferrer" fx-ignore>` + html.EscapeString(path.Base(raw)) + `</a>`
1781+ }
1782+ return `<figure data-social-media>` + media + `</figure>`
1783+}
1784+
1785+func socialMediaInline(item roomAttachment) string {
1786+ raw := socialMediaURL(item.URL)
1787+ if raw == "" {
1788+ return ""
1789+ }
1790+ href := template.HTMLEscapeString(raw)
1791+ alt := template.HTMLEscapeString(roomAttachmentLabel(item))
1792+ if alt == "" {
1793+ alt = "Media"
1794+ }
1795+ switch mime := strings.ToLower(item.MIME); {
1796+ case strings.HasPrefix(mime, "image/") && mime != "image/svg+xml":
1797+ return `<img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer">`
1798+ case strings.HasPrefix(mime, "video/"):
1799+ return `<video controls preload="metadata" playsinline referrerpolicy="no-referrer" src="` + href + `"></video>`
1800+ case strings.HasPrefix(mime, "audio/"):
1801+ return `<audio controls preload="metadata" src="` + href + `"></audio>`
1802+ default:
1803+ return `<a data-social-media href="` + href + `" rel="noopener noreferrer" fx-ignore>` + html.EscapeString(path.Base(raw)) + `</a>`
1804+ }
1805+}
1806+
1807+// Media metadata is preferred, while familiar file extensions keep older
1808+// clients' ordinary image and video links useful without a server fetch.
1809+func socialAttachments(row map[string]any) []roomAttachment {
1810+ items := roomAttachments(row)
1811+ seen := map[string]bool{}
1812+ for i := range items {
1813+ if !strings.Contains(items[i].MIME, "/") {
1814+ items[i].MIME = socialMIME(items[i].URL, items[i].MIME)
1815+ }
1816+ seen[items[i].URL] = true
1817+ }
1818+ content := plainString(row["content"])
1819+ replaceSocialOutsideCode(content, func(text string) string {
1820+ for _, match := range roomAttachmentReference.FindAllStringSubmatch(text, -1) {
1821+ raw := attachmentReferenceURL(match)
1822+ if seen[raw] || socialMediaURL(raw) == "" || len(items) >= 16 {
1823+ continue
1824+ }
1825+ typ := socialMIME(raw, "")
1826+ if typ == "" && strings.HasPrefix(match[0], "![") && plainString(row["kind"]) == "30023" {
1827+ typ = "image/jpeg"
1828+ }
1829+ if typ == "" {
1830+ continue
1831+ }
1832+ seen[raw] = true
1833+ items = append(items, roomAttachment{URL: raw, MIME: typ, Alt: match[1]})
1834+ }
1835+ return text
1836+ })
1837+ return items
1838+}
1839+func socialMIME(raw, short string) string {
1840+ if short == "" {
1841+ u, err := url.Parse(raw)
1842+ if err != nil {
1843+ return ""
1844+ }
1845+ short = strings.TrimPrefix(strings.ToLower(path.Ext(u.Path)), ".")
1846+ }
1847+ return map[string]string{"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "apng": "image/apng", "mp4": "video/mp4", "m4v": "video/mp4", "webm": "video/webm", "mov": "video/quicktime", "mp3": "audio/mpeg", "m4a": "audio/mp4", "wav": "audio/wav", "ogg": "audio/ogg", "opus": "audio/ogg"}[strings.ToLower(short)]
1848+}
1849diff --git a/internal/webui/social_content_test.go b/internal/webui/social_content_test.go
1850new file mode 100644
1851index 0000000..93b439d
1852--- /dev/null
1853+++ b/internal/webui/social_content_test.go
1854@@ -0,0 +1,121 @@
1855+package webui
1856+
1857+import (
1858+ "strings"
1859+ "testing"
1860+)
1861+
1862+func TestSocialBodyRendersMarkdownAndMedia(t *testing.T) {
1863+ row := map[string]any{
1864+ "kind": "30023",
1865+ "content": "# Hello\n\nA **note** with a table:\n\n| one | two |\n| --- | --- |\n| a | b |\n\n![sun](https://cdn.example.test/sun.png)",
1866+ "tags": [][]string{{"imeta", "url https://cdn.example.test/sun.png", "m image/png", "alt sunset"}},
1867+ }
1868+ got := string(socialBody(row))
1869+ for _, want := range []string{"<h1>Hello</h1>", "<strong>note</strong>", "data-markdown-table", "<img", `alt="sunset"`, `loading="lazy"`} {
1870+ if !strings.Contains(got, want) {
1871+ t.Errorf("social body missing %q: %s", want, got)
1872+ }
1873+ }
1874+ if strings.Count(got, "sun.png") != 1 {
1875+ t.Fatalf("media should have one image: %s", got)
1876+ }
1877+}
1878+
1879+func TestSocialBodySupportsMediaTypesAndIgnoresCode(t *testing.T) {
1880+ row := map[string]any{
1881+ "kind": "1",
1882+ "content": "https://cdn.example.test/clip.mp4\n\nhttps://cdn.example.test/song.mp3\n\n```\nhttps://cdn.example.test/hidden.png\n```",
1883+ "tags": [][]string{{"imeta", "url https://cdn.example.test/clip.mp4", "m video/mp4"}, {"imeta", "url https://cdn.example.test/song.mp3", "m audio/mpeg"}, {"imeta", "url https://cdn.example.test/hidden.png", "m image/png"}},
1884+ }
1885+ got := string(socialBody(row))
1886+ if !strings.Contains(got, `<video controls`) || !strings.Contains(got, `<audio controls`) {
1887+ t.Fatalf("native media missing: %s", got)
1888+ }
1889+ if !strings.Contains(got, "hidden.png") || strings.Contains(got, "<iframe") {
1890+ t.Fatalf("code or iframe leaked into media: %s", got)
1891+ }
1892+}
1893+
1894+func TestSocialNoteIsPlaintext(t *testing.T) {
1895+ got := string(socialBody(map[string]any{"kind": "1", "content": "**literal**\nhttps://cdn.example.test/note.png", "tags": [][]string{{"imeta", "url https://cdn.example.test/note.png", "m image/png"}}}))
1896+ if strings.Contains(got, "<strong>") || !strings.Contains(got, "**literal**") || !strings.Contains(got, "<img") {
1897+ t.Fatalf("note rendering = %s", got)
1898+ }
1899+}
1900+
1901+func TestSocialMediaSkipsInlineAndFencedCode(t *testing.T) {
1902+ url := "https://cdn.example.test/hidden.png"
1903+ row := map[string]any{"kind": "30023", "content": "`![x](" + url + ")`\n\n````\n![x](" + url + ")\n````", "tags": [][]string{{"imeta", "url " + url, "m image/png"}}}
1904+ got := string(socialBody(row))
1905+ if strings.Contains(got, "<img") || strings.Contains(got, "<figure") {
1906+ t.Fatalf("code media was embedded: %s", got)
1907+ }
1908+ if !strings.Contains(got, "hidden.png") {
1909+ t.Fatalf("code source missing: %s", got)
1910+ }
1911+}
1912+
1913+func TestSocialBodyEscapesUnsafeContent(t *testing.T) {
1914+ got := string(socialBody(map[string]any{"content": `<script>alert(1)</script> [x](javascript:alert(1)) https://cdn.example.test/a.svg`, "tags": [][]string{{"imeta", "url https://cdn.example.test/a.svg", "m image/svg+xml"}}}))
1915+ if strings.Contains(got, "<script") || strings.Contains(got, `href="javascript:`) || strings.Contains(got, "<iframe") {
1916+ t.Fatalf("unsafe social markup: %s", got)
1917+ }
1918+ if !strings.Contains(got, "a.svg") {
1919+ t.Fatalf("safe media link missing: %s", got)
1920+ }
1921+}
1922+
1923+func TestSocialPreviewAndMediaURL(t *testing.T) {
1924+ if got := socialPreview(map[string]any{"content": "hello\nworld"}); got != "hello world" {
1925+ t.Fatalf("preview = %q", got)
1926+ }
1927+ if got := socialMediaURL("javascript:alert(1)"); got != "" {
1928+ t.Fatalf("unsafe URL = %q", got)
1929+ }
1930+ if got := socialMediaURL("https://example.test/a?x=1"); got != "https://example.test/a?x=1" {
1931+ t.Fatalf("safe URL = %q", got)
1932+ }
1933+}
1934+
1935+func TestSocialArticleMediaWithoutMetadata(t *testing.T) {
1936+ got := string(socialBody(map[string]any{"kind": 30023, "content": "![Cover](https://cdn.test/a.jpg)\n\n[Watch](https://cdn.test/a.mp4)\n\nhttps://cdn.test/a.mp3"}))
1937+ for _, want := range []string{`<img src="https://cdn.test/a.jpg"`, `alt="Cover"`, `<video controls`, `<audio controls`} {
1938+ if !strings.Contains(got, want) {
1939+ t.Errorf("missing %s: %s", want, got)
1940+ }
1941+ }
1942+ if strings.Contains(got, "!Cover") || strings.Contains(got, "<p><figure") {
1943+ t.Fatal(got)
1944+ }
1945+}
1946+func TestSocialNoteImageWithoutMetadataAndInvalidClientMIME(t *testing.T) {
1947+ for _, tags := range [][][]string{nil, {{"imeta", "url https://cdn.test/a.jpg", "m jpeg"}}} {
1948+ got := string(socialBody(map[string]any{"kind": 1, "content": "A photo\nhttps://cdn.test/a.jpg", "tags": tags}))
1949+ if !strings.Contains(got, `<img src="https://cdn.test/a.jpg"`) {
1950+ t.Fatal(got)
1951+ }
1952+ }
1953+}
1954+func TestSocialMediaTokenCannotBeForgedInCode(t *testing.T) {
1955+ raw := "https://cdn.test/a.png"
1956+ got := string(socialBody(map[string]any{"kind": 30023, "content": "`" + socialMediaToken(raw) + "`\n\n![A](" + raw + ")"}))
1957+ if strings.Count(got, "<img") != 1 || !strings.Contains(got, "<code>"+socialMediaToken(raw)+"</code>") {
1958+ t.Fatal(got)
1959+ }
1960+}
1961+
1962+func TestSocialMediaReservesValidMetadataDimensions(t *testing.T) {
1963+ for _, dimension := range []string{"1170x1613", "1170.0x1613.0"} {
1964+ got := string(socialBody(map[string]any{"kind": 1, "content": "https://cdn.test/a.jpg", "tags": [][]string{{"imeta", "url https://cdn.test/a.jpg", "m image/jpeg", "dim " + dimension}}}))
1965+ if !strings.Contains(got, `width="1170" height="1613"`) {
1966+ t.Fatal(got)
1967+ }
1968+ }
1969+ for _, dimension := range []string{"0x3", "NaNx3", "1e90x3", `1" onload="bad`} {
1970+ got := string(socialBody(map[string]any{"kind": 1, "content": "https://cdn.test/a.jpg", "tags": [][]string{{"imeta", "url https://cdn.test/a.jpg", "m image/jpeg", "dim " + dimension}}}))
1971+ if strings.Contains(got, "width=") || strings.Contains(got, "onload=") {
1972+ t.Fatal(got)
1973+ }
1974+ }
1975+}
1976diff --git a/internal/webui/social_mentions.go b/internal/webui/social_mentions.go
1977new file mode 100644
1978index 0000000..690e6a6
1979--- /dev/null
1980+++ b/internal/webui/social_mentions.go
1981@@ -0,0 +1,72 @@
1982+package webui
1983+
1984+import (
1985+ "encoding/hex"
1986+ "html"
1987+ "net/url"
1988+ "regexp"
1989+ "strings"
1990+)
1991+
1992+var socialNostrAnchor = regexp.MustCompile(`<a href="/open\?target=([^"&]+)">((?:nostr:|web\+nostr:)[^<]+)</a>`)
1993+
1994+// socialNostrLinks gives NIP-27 references useful labels after autolinking.
1995+// It only accepts the exact anchors emitted by autolinkOutsideTags.
1996+func socialNostrLinks(rendered string) string {
1997+ return socialNostrAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string {
1998+ match := socialNostrAnchor.FindStringSubmatch(anchor)
1999+ target, err := url.QueryUnescape(html.UnescapeString(match[1]))
2000+ if err != nil || !strings.EqualFold(target, html.UnescapeString(match[2])) {
2001+ return anchor
2002+ }
2003+ path, label, ok := socialNostrDestination(target)
2004+ if !ok {
2005+ return anchor
2006+ }
2007+ return `<a href="` + html.EscapeString(path) + `">` + label + `</a>`
2008+ })
2009+}
2010+
2011+func socialNostrDestination(target string) (string, string, bool) {
2012+ value := strings.Trim(strings.TrimSpace(target), "/")
2013+ for _, prefix := range []string{"web+nostr:", "nostr:"} {
2014+ if strings.HasPrefix(strings.ToLower(value), prefix) {
2015+ value = value[len(prefix):]
2016+ break
2017+ }
2018+ }
2019+ hrp, data, ok := bech32Decode(strings.ToLower(value))
2020+ if !ok {
2021+ return "", "", false
2022+ }
2023+ switch hrp {
2024+ case "npub", "nprofile":
2025+ fields := data
2026+ if hrp == "nprofile" {
2027+ fields = tlvFields(data)[0]
2028+ }
2029+ if len(fields) != 32 {
2030+ return "", "", false
2031+ }
2032+ pubkey := hex.EncodeToString(fields)
2033+ return "/social?author=" + pubkey, `<nostr-name pubkey="` + pubkey + `">` + shortID(pubkey) + `</nostr-name>`, true
2034+ case "note", "nevent":
2035+ id := data
2036+ if hrp == "nevent" {
2037+ id = tlvFields(data)[0]
2038+ }
2039+ if len(id) != 32 {
2040+ return "", "", false
2041+ }
2042+ return "/e/" + hex.EncodeToString(id), "Quoted post", true
2043+ case "naddr":
2044+ path, ok := nostrTargetPath(target)
2045+ label := "View post"
2046+ if strings.HasPrefix(path, "/a/30023:") {
2047+ label = "Article"
2048+ }
2049+ return path, label, ok
2050+ default:
2051+ return "", "", false
2052+ }
2053+}
2054diff --git a/internal/webui/social_mentions_test.go b/internal/webui/social_mentions_test.go
2055new file mode 100644
2056index 0000000..67accad
2057--- /dev/null
2058+++ b/internal/webui/social_mentions_test.go
2059@@ -0,0 +1,42 @@
2060+package webui
2061+
2062+import (
2063+ "encoding/binary"
2064+ "encoding/hex"
2065+ "strings"
2066+ "testing"
2067+)
2068+
2069+func TestSocialNostrLinksRewritesKnownReferences(t *testing.T) {
2070+ pubkey := "5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369"
2071+ id := "0f8fc45a6e3cd2d022cf68f3ea0f413665a6d92c3d783e3ab6c2bc324432b5af"
2072+ pubkeyBytes, _ := hex.DecodeString(pubkey)
2073+ idBytes, _ := hex.DecodeString(id)
2074+ kind := make([]byte, 4)
2075+ binary.BigEndian.PutUint32(kind, 30023)
2076+ naddrData := append([]byte{0, 5}, []byte("hello")...)
2077+ naddrData = append(naddrData, append([]byte{2, 32}, pubkeyBytes...)...)
2078+ naddrData = append(naddrData, append([]byte{3, 4}, kind...)...)
2079+ refs := []struct {
2080+ name string
2081+ ref string
2082+ want string
2083+ }{
2084+ {"profile", "nostr:" + bech32("npub", pubkeyBytes), `<a href="/social?author=` + pubkey + `"><nostr-name pubkey="` + pubkey + `">` + shortID(pubkey) + `</nostr-name></a>`},
2085+ {"event", "nostr:" + bech32("note", idBytes), `<a href="/e/` + id + `">Quoted post</a>`},
2086+ {"article", "nostr:" + bech32("naddr", naddrData), `<a href="/a/30023:` + pubkey + `:hello">Article</a>`},
2087+ }
2088+ for _, test := range refs {
2089+ rendered := `<p>See <a href="/open?target=` + test.ref + `">` + test.ref + `</a> today.</p>`
2090+ if got := socialNostrLinks(rendered); !strings.Contains(got, test.want) {
2091+ t.Errorf("%s: got %q, want %q", test.name, got, test.want)
2092+ }
2093+ }
2094+}
2095+
2096+func TestSocialNostrLinksLeavesUnknownMarkupAndCodeAlone(t *testing.T) {
2097+ input := `<p><a href="/open?target=nostr%3Ainvalid">nostr:invalid</a> <code>nostr:invalid</code> <a href="https://example.com">nostr:invalid</a></p>`
2098+ if got := socialNostrLinks(input); got != input {
2099+ t.Fatalf("got %q, want unchanged markup", got)
2100+ }
2101+}
2102diff --git a/internal/webui/social_pages.go b/internal/webui/social_pages.go
2103new file mode 100644
2104index 0000000..01a69fc
2105--- /dev/null
2106+++ b/internal/webui/social_pages.go
2107@@ -0,0 +1,223 @@
2108+package webui
2109+
2110+import (
2111+ "encoding/json"
2112+ "io"
2113+ "net/http"
2114+ "net/url"
2115+ "strings"
2116+)
2117+
2118+func (a *App) handleSocialRoute(w http.ResponseWriter, r *http.Request) bool {
2119+ p := r.URL.Path
2120+ if p != "/articles" && p != "/social" && p != "/social.json" && p != "/social.xml" && !strings.HasPrefix(p, "/social/") {
2121+ return false
2122+ }
2123+ if !a.backend.Policy().Features.Pages {
2124+ http.NotFound(w, r)
2125+ return true
2126+ }
2127+ if p == "/social/preview" && r.Method == http.MethodPost {
2128+ a.socialPreviewPage(w, r)
2129+ return true
2130+ }
2131+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
2132+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
2133+ return true
2134+ }
2135+ if p == "/articles" {
2136+ target := requestPrefix(r) + "/social"
2137+ if r.URL.RawQuery != "" {
2138+ target += "?" + r.URL.RawQuery
2139+ }
2140+ http.Redirect(w, r, target, http.StatusMovedPermanently)
2141+ return true
2142+ }
2143+ if p == "/social.json" || p == "/social.xml" {
2144+ a.socialSyndication(w, r)
2145+ return true
2146+ }
2147+ a.socialPage(w, r)
2148+ return true
2149+}
2150+
2151+func socialParameters(q url.Values) map[string]any {
2152+ params := map[string]any{"limit": 30}
2153+ for _, key := range []string{"kind", "author", "q", "cursor", "address"} {
2154+ if value := q.Get(key); value != "" {
2155+ params[key] = value
2156+ }
2157+ }
2158+ if q.Get("kind") == "" {
2159+ switch q.Get("kinds") {
2160+ case "1":
2161+ params["kind"] = "notes"
2162+ case "30023":
2163+ params["kind"] = "articles"
2164+ }
2165+ }
2166+ return params
2167+}
2168+
2169+func (a *App) socialPage(w http.ResponseWriter, r *http.Request) {
2170+ actor, _ := a.resolveActor(r)
2171+ params := socialParameters(r.URL.Query())
2172+ method, tab := "browsesocial", "social"
2173+ if strings.HasPrefix(r.URL.Path, "/social/") {
2174+ params["id"] = strings.TrimPrefix(r.URL.Path, "/social/")
2175+ method, tab = "browsesocialthread", "social-thread"
2176+ } else if r.URL.Query().Get("address") != "" {
2177+ method, tab = "browsesocialthread", "social-thread"
2178+ }
2179+ raw, err := json.Marshal(params)
2180+ if err != nil {
2181+ http.Error(w, "invalid social query", http.StatusBadRequest)
2182+ return
2183+ }
2184+ result, err := a.backend.Query(r.Context(), method, []json.RawMessage{raw}, actor)
2185+ data := PageData{Tab: tab, Title: "Social | " + a.backend.Slug(), Event: result, Query: r.URL.Query()}
2186+ if tab == "social" {
2187+ data.Feed = browseRows(result)
2188+ }
2189+ if err != nil {
2190+ data.Error = err.Error()
2191+ if strings.HasPrefix(err.Error(), "not found:") {
2192+ w.WriteHeader(http.StatusNotFound)
2193+ }
2194+ }
2195+ a.render(w, r, data)
2196+}
2197+
2198+func (a *App) socialPreviewPage(w http.ResponseWriter, r *http.Request) {
2199+ actor, err := a.resolveActor(r)
2200+ if err != nil || actor == "" {
2201+ http.Error(w, "Sign in to preview a draft.", http.StatusUnauthorized)
2202+ return
2203+ }
2204+ var input struct {
2205+ Content string `json:"content"`
2206+ Kind int `json:"kind"`
2207+ }
2208+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024))
2209+ decoder.DisallowUnknownFields()
2210+ if err := decoder.Decode(&input); err != nil {
2211+ http.Error(w, "Invalid preview.", http.StatusBadRequest)
2212+ return
2213+ }
2214+ if decoder.Decode(new(any)) != io.EOF || (input.Kind != 1 && input.Kind != 30023 && input.Kind != 1111) {
2215+ http.Error(w, "Invalid preview.", http.StatusBadRequest)
2216+ return
2217+ }
2218+ w.Header().Set("Cache-Control", "private, no-store")
2219+ writeJSON(w, http.StatusOK, map[string]any{"html": a.socialBody(map[string]any{"content": input.Content, "kind": input.Kind})})
2220+}
2221+
2222+func socialContext(value any, data PageData) map[string]any {
2223+ row := valueMap(value)
2224+ out := make(map[string]any, len(row)+4)
2225+ for key, value := range row {
2226+ out[key] = value
2227+ }
2228+ out["viewer"] = data.Actor
2229+ out["detail"] = data.Tab == "social-thread"
2230+ out["relay"] = wsURL(data.URL)
2231+ out["thread"] = valueMap(data.Event)["post"]
2232+ out["parent_url"] = socialReferenceURL(row, "parent_")
2233+ out["root_url"] = socialReferenceURL(row, "root_")
2234+ parent := plainString(row["parent_id"])
2235+ thread := valueMap(out["thread"])
2236+ out["nested"] = parent != "" && parent != plainString(thread["id"])
2237+ return out
2238+}
2239+
2240+func socialReferenceURL(row map[string]any, prefix string) string {
2241+ if address := plainString(row[prefix+"address"]); strings.HasPrefix(address, "30023:") {
2242+ return "/social?address=" + url.QueryEscape(address)
2243+ }
2244+ if id := plainString(row[prefix+"id"]); hexID.MatchString(id) {
2245+ return "/social/" + id
2246+ }
2247+ return ""
2248+}
2249+
2250+func socialThreadPage(path string, query url.Values, cursor string) string {
2251+ next := url.Values{}
2252+ if address := query.Get("address"); address != "" {
2253+ next.Set("address", address)
2254+ }
2255+ next.Set("cursor", cursor)
2256+ return path + "?" + next.Encode()
2257+}
2258+
2259+func socialReactionCount(value any, content string) int {
2260+ for _, row := range browseRows(valueMap(value)["reactions"]) {
2261+ if plainString(valueMap(row)["content"]) == content {
2262+ return int(unixSeconds(valueMap(row)["count"]))
2263+ }
2264+ }
2265+ return 0
2266+}
2267+
2268+func socialReacted(value any, content string) bool {
2269+ for _, row := range browseRows(valueMap(value)["reactions"]) {
2270+ if plainString(valueMap(row)["content"]) == content {
2271+ return valueMap(row)["reacted"] == true
2272+ }
2273+ }
2274+ return false
2275+}
2276+
2277+func socialQuery(query url.Values, key, value string) string {
2278+ next := url.Values{}
2279+ for _, name := range []string{"kind", "author", "q", "compose", "address"} {
2280+ if v := query.Get(name); v != "" {
2281+ next.Set(name, v)
2282+ }
2283+ }
2284+ if value == "" {
2285+ next.Del(key)
2286+ } else {
2287+ next.Set(key, value)
2288+ }
2289+ if encoded := next.Encode(); encoded != "" {
2290+ return "/social?" + encoded
2291+ }
2292+ return "/social"
2293+}
2294+
2295+func socialURL(value any) string {
2296+ row := valueMap(value)
2297+ if address := plainString(row["address"]); address != "" {
2298+ return "/social?address=" + url.QueryEscape(address)
2299+ }
2300+ return "/social/" + url.PathEscape(plainString(row["id"]))
2301+}
2302+
2303+func (a *App) socialSyndication(w http.ResponseWriter, r *http.Request) {
2304+ actor, _ := a.resolveActor(r)
2305+ raw, err := json.Marshal(socialParameters(r.URL.Query()))
2306+ if err != nil {
2307+ http.Error(w, "invalid social query", http.StatusBadRequest)
2308+ return
2309+ }
2310+ result, err := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{raw}, actor)
2311+ if err != nil {
2312+ http.Error(w, err.Error(), publicErrorStatus(err))
2313+ return
2314+ }
2315+ items := make([]map[string]any, 0)
2316+ for _, row := range browseRows(result) {
2317+ if item, ok := articleItem(row, a.backend.URL()); ok {
2318+ item["url"] = strings.TrimSuffix(a.backend.URL(), "/") + socialURL(row)
2319+ if title := plainString(valueMap(row)["title"]); title != "" {
2320+ item["title"] = title
2321+ }
2322+ items = append(items, item)
2323+ }
2324+ }
2325+ if r.URL.Path == "/social.xml" {
2326+ a.writeAtom(w, items)
2327+ return
2328+ }
2329+ 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})
2330+}
2331diff --git a/internal/webui/social_pages_test.go b/internal/webui/social_pages_test.go
2332new file mode 100644
2333index 0000000..a1e15e7
2334--- /dev/null
2335+++ b/internal/webui/social_pages_test.go
2336@@ -0,0 +1,138 @@
2337+package webui
2338+
2339+import (
2340+ "context"
2341+ "encoding/json"
2342+ "net/http"
2343+ "net/http/httptest"
2344+ "strings"
2345+ "testing"
2346+
2347+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
2348+)
2349+
2350+type socialBackend struct {
2351+ fakeBackend
2352+ method string
2353+ query map[string]any
2354+}
2355+
2356+func (b *socialBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
2357+ if method != "browsesocial" && method != "browsesocialthread" {
2358+ return b.fakeBackend.Query(ctx, method, params, actor)
2359+ }
2360+ b.method = method
2361+ if len(params) > 0 {
2362+ if err := json.Unmarshal(params[0], &b.query); err != nil {
2363+ return nil, err
2364+ }
2365+ }
2366+ post := map[string]any{"id": strings.Repeat("b", 64), "kind": 1, "pubkey": strings.Repeat("a", 64), "created_at": 1789000000, "content": "A small moment worth sharing.", "author_name": "River", "author_picture": "https://example.com/avatar.png", "reply_count": 2, "reactions": []any{map[string]any{"content": "+", "count": 3, "reacted": false}, map[string]any{"content": ":party:", "image": "https://example.com/party.png", "count": 1}}, "read_path": "/social/" + strings.Repeat("b", 64)}
2367+ if method == "browsesocialthread" {
2368+ return map[string]any{"post": post, "comments": []any{}, "next_cursor": ""}, nil
2369+ }
2370+ return map[string]any{"items": []any{post}, "next_cursor": "older-page", "stats": map[string]any{"notes": 1, "articles": 0, "comments": 2}}, nil
2371+}
2372+
2373+func TestSocialPageRendersFeedWithoutJavaScript(t *testing.T) {
2374+ b := &socialBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
2375+ a, err := New(b, Options{})
2376+ if err != nil {
2377+ t.Fatal(err)
2378+ }
2379+ r := httptest.NewRecorder()
2380+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/social?kind=notes&author="+strings.Repeat("a", 64)+"&cursor=older&q=moment", nil))
2381+ for _, want := range []string{"Social", "A small moment worth sharing.", "River", "/social", "older-page", `data-reaction-emoji`, `alt=":party:"`} {
2382+ if r.Code != http.StatusOK || !strings.Contains(r.Body.String(), want) {
2383+ t.Fatalf("missing %q: status=%d body=%s", want, r.Code, r.Body.String())
2384+ }
2385+ }
2386+ if b.method != "browsesocial" || b.query["kind"] != "notes" || b.query["cursor"] != "older" || b.query["q"] != "moment" {
2387+ t.Fatalf("query: %s %#v", b.method, b.query)
2388+ }
2389+}
2390+
2391+func TestSocialThreadAndLegacyArticleRoute(t *testing.T) {
2392+ b := &socialBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
2393+ a, err := New(b, Options{})
2394+ if err != nil {
2395+ t.Fatal(err)
2396+ }
2397+ r := httptest.NewRecorder()
2398+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/social/"+strings.Repeat("b", 64), nil))
2399+ if r.Code != http.StatusOK || b.method != "browsesocialthread" || b.query["id"] != strings.Repeat("b", 64) || !strings.Contains(r.Body.String(), "A small moment worth sharing.") {
2400+ t.Fatalf("thread: %d %s", r.Code, r.Body.String())
2401+ }
2402+ r = httptest.NewRecorder()
2403+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/articles?author=alice", nil))
2404+ if r.Code != http.StatusMovedPermanently || r.Header().Get("Location") != "/social?author=alice" {
2405+ t.Fatalf("legacy: %d %s", r.Code, r.Header().Get("Location"))
2406+ }
2407+}
2408+
2409+func TestNostrReferenceRoutesUseSocialThread(t *testing.T) {
2410+ owner := strings.Repeat("a", 64)
2411+ b := &socialBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}
2412+ a, err := New(b, Options{})
2413+ if err != nil {
2414+ t.Fatal(err)
2415+ }
2416+ id := strings.Repeat("b", 64)
2417+ r := httptest.NewRecorder()
2418+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/e/"+id, nil))
2419+ if r.Code != http.StatusOK || b.method != "browsesocialthread" || b.query["id"] != id || !strings.Contains(r.Body.String(), "A small moment worth sharing.") {
2420+ t.Fatalf("event reference: status=%d method=%s query=%#v", r.Code, b.method, b.query)
2421+ }
2422+
2423+ b.method, b.query = "", nil
2424+ address := "30023:" + owner + ":hello"
2425+ r = httptest.NewRecorder()
2426+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/a/"+address, nil))
2427+ if r.Code != http.StatusOK || b.method != "browsesocialthread" || b.query["address"] != address {
2428+ t.Fatalf("article reference: status=%d method=%s query=%#v", r.Code, b.method, b.query)
2429+ }
2430+}
2431+
2432+func TestNostrReferenceRoutesKeepLegacyFallback(t *testing.T) {
2433+ owner := strings.Repeat("a", 64)
2434+ b := &fakeBackend{policy: policy.Defaults(owner)}
2435+ a, err := New(b, Options{})
2436+ if err != nil {
2437+ t.Fatal(err)
2438+ }
2439+ r := httptest.NewRecorder()
2440+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/a/legacy-name", nil))
2441+ if r.Code != http.StatusOK || b.call != "queryevents:" {
2442+ t.Fatalf("legacy article fallback: status=%d call=%q", r.Code, b.call)
2443+ }
2444+}
2445+
2446+func TestSocialRespectsPagesFeature(t *testing.T) {
2447+ b := &socialBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
2448+ b.policy.Features.Pages = false
2449+ a, err := New(b, Options{})
2450+ if err != nil {
2451+ t.Fatal(err)
2452+ }
2453+ for _, path := range []string{"/social", "/social/" + strings.Repeat("b", 64), "/social.json", "/articles"} {
2454+ r := httptest.NewRecorder()
2455+ a.ServeHTTP(r, httptest.NewRequest(http.MethodGet, path, nil))
2456+ if r.Code != http.StatusNotFound {
2457+ t.Fatalf("%s status %d", path, r.Code)
2458+ }
2459+ }
2460+}
2461+
2462+func TestSocialContextLinksToParentAndRoot(t *testing.T) {
2463+ root, parent := strings.Repeat("a", 64), strings.Repeat("b", 64)
2464+ data := PageData{Tab: "social-thread", Event: map[string]any{"post": map[string]any{"id": root}}}
2465+ row := socialContext(map[string]any{"id": strings.Repeat("c", 64), "parent_id": parent, "root_id": root}, data)
2466+ if row["parent_url"] != "/social/"+parent || row["root_url"] != "/social/"+root || row["nested"] != true {
2467+ t.Fatalf("missing nested context: %#v", row)
2468+ }
2469+ address := "30023:" + root + ":essay:part-one"
2470+ row = socialContext(map[string]any{"id": parent, "parent_id": root, "parent_address": address, "root_address": address}, data)
2471+ if row["nested"] != false || !strings.Contains(plainString(row["parent_url"]), "address=30023%3A") {
2472+ t.Fatalf("missing article parent: %#v", row)
2473+ }
2474+}
2475diff --git a/internal/webui/social_reference_routes.go b/internal/webui/social_reference_routes.go
2476new file mode 100644
2477index 0000000..c521e37
2478--- /dev/null
2479+++ b/internal/webui/social_reference_routes.go
2480@@ -0,0 +1,59 @@
2481+package webui
2482+
2483+import (
2484+ "encoding/json"
2485+ "net/http"
2486+ "strings"
2487+)
2488+
2489+// socialReferencePage tries the Social renderer for NIP-19 event references.
2490+// The generic event page remains the fallback for event kinds that Social does
2491+// not publish or for backends that predate the Social browse methods.
2492+func (a *App) socialReferencePage(writer http.ResponseWriter, request *http.Request, identifier string, address bool, actor string) bool {
2493+ if !a.backend.Policy().Features.Pages {
2494+ return false
2495+ }
2496+ if address && !socialArticleAddress(identifier) {
2497+ return false
2498+ }
2499+ params := map[string]any{"limit": 30}
2500+ if cursor := request.URL.Query().Get("cursor"); cursor != "" {
2501+ params["cursor"] = cursor
2502+ }
2503+ if address {
2504+ params["address"] = identifier
2505+ } else {
2506+ params["id"] = identifier
2507+ }
2508+ raw, err := json.Marshal(params)
2509+ if err != nil {
2510+ return false
2511+ }
2512+ result, err := a.backend.Query(request.Context(), "browsesocialthread", []json.RawMessage{raw}, actor)
2513+ if err != nil || !socialThreadPost(result) {
2514+ return false
2515+ }
2516+ data := PageData{
2517+ Event: result,
2518+ Query: request.URL.Query(),
2519+ Tab: "social-thread",
2520+ Title: "Social | " + a.backend.Slug(),
2521+ }
2522+ a.render(writer, request, data)
2523+ return true
2524+}
2525+
2526+func socialArticleAddress(value string) bool {
2527+ parts := strings.SplitN(value, ":", 3)
2528+ return len(parts) == 3 && parts[0] == "30023" && hexID.MatchString(parts[1])
2529+}
2530+
2531+func socialThreadPost(value any) bool {
2532+ post, ok := valueMap(value)["post"]
2533+ if !ok {
2534+ return false
2535+ }
2536+ row := valueMap(post)
2537+ kind := int(unixSeconds(row["kind"]))
2538+ return kind == 1 || kind == 1111 || kind == 30023
2539+}
2540diff --git a/internal/webui/style.css b/internal/webui/style.css
2541index 4fab4e8..9f945c8 100644
2542--- a/internal/webui/style.css
2543+++ b/internal/webui/style.css
2544@@ -406,3 +406,55 @@ room-message[data-own]{margin-left:12px;padding-right:10px}
2545 room-compose{bottom:calc(28px + env(safe-area-inset-bottom,0px));margin:8px -2.5ch 0;padding:8px 12px;z-index:1}
2546 body:has(#room) #layout>tbody>tr>td#aside,body:has(#thread) #layout>tbody>tr>td#aside{display:none}
2547 }
2548+
2549+/* Social: a calm reading stream that keeps short notes quick and gives
2550+ long-form posts a clear title, image and reading measure. */
2551+#social,#social-thread{max-width:760px}
2552+#social-head{border-bottom:1px solid var(--line);padding-bottom:10px;margin-bottom:0}
2553+#social-head h1{margin-bottom:4px}#social-head p{color:var(--muted);margin:0;max-width:60ch}
2554+#social-views{border-bottom:1px solid var(--line);margin:0 0 4px;padding:0}
2555+#social-views a{display:inline-block;padding:10px 14px 8px;color:var(--muted);border-bottom:2px solid transparent;font:12px var(--mono)}
2556+#social-views a:first-child{padding-left:0}#social-views a[aria-current=page]{color:var(--accent);border-color:var(--accent)}
2557+#social-feed{list-style:none;margin:0;padding:0}
2558+#social-feed>li{border-bottom:1px solid var(--line);padding:16px 0 18px;max-width:720px}
2559+#social-feed>li[data-kind=article]{padding-top:20px}
2560+[data-social-byline]{display:table;width:100%;margin-bottom:9px;font:12px/1.35 var(--mono);color:var(--muted)}
2561+[data-social-byline]>span{display:table-cell;vertical-align:middle}[data-social-byline]>span:last-child{width:auto;padding-left:9px}
2562+[data-social-byline] b{color:var(--ink);font-weight:600;font-family:var(--sans)}[data-social-byline] time{margin-left:5px;color:var(--muted)}[data-social-byline] small{margin-left:7px;color:var(--bark)}
2563+[data-social-avatar]{width:34px;height:34px;display:table-cell;box-sizing:border-box;border:1px solid var(--line);border-radius:50%;background:var(--soft);color:var(--accent);text-align:center;vertical-align:middle;font:14px/32px var(--mono);overflow:hidden}
2564+[data-social-avatar] img{display:block;width:100%;height:100%;object-fit:cover}
2565+[data-post-body]{font:15px/1.58 var(--sans);overflow-wrap:anywhere}[data-post-body] p{margin:0 0 10px}[data-post-body]>:last-child{margin-bottom:0}[data-post-body] h1,[data-post-body] h2,[data-post-body] h3{margin:8px 0;font-size:16px}[data-post-body] ul,[data-post-body] ol{margin:0 0 10px;padding-left:22px}[data-post-body] pre{margin:8px 0;padding:9px;background:var(--rail);border:1px solid var(--line);overflow:auto;font:12px/1.45 var(--mono)}[data-post-body] code{font:12px var(--mono)}
2566+[data-post-body=article]{font-size:16px;line-height:1.65;max-width:680px}
2567+[data-post-body=article] h1,[data-post-body=article] h2,[data-post-body=article] h3{font-size:18px}
2568+[data-post-body] a{overflow-wrap:anywhere}
2569+[data-post-body] [data-markdown-table]{font-size:13px}
2570+[data-post-body=article]~[data-social-actions]{margin-top:12px}
2571+[data-post-summary]{font-size:15px;color:var(--muted);margin:0 0 10px;max-width:65ch}
2572+[data-post-media]{display:block;margin:12px 0;border:1px solid var(--line);background:var(--rail)}[data-post-media] img{display:block;width:100%;max-height:380px;object-fit:cover}
2573+[data-read-time]{display:block;margin-top:8px;color:var(--muted);font:12px var(--mono)}
2574+[data-post-embed]{margin:12px 0 0}[data-post-embed] img,[data-post-embed] video{display:block;max-width:100%;max-height:480px;border:1px solid var(--line);background:#000}[data-post-embed] audio{display:block;max-width:100%}[data-post-embed] figcaption{margin-top:4px;color:var(--muted);font:12px var(--mono);overflow-wrap:anywhere}
2575+[data-social-actions]{margin-top:10px;color:var(--muted);font:12px var(--mono)}[data-social-actions]>*{margin-right:12px}[data-social-actions] button{padding:2px 6px;font:12px var(--mono);border-radius:12px}[data-social-actions] button[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:var(--soft)}
2576+#social-pages{font:12px var(--mono);padding:14px 0}#social-empty{padding:24px 0;color:var(--muted)}
2577+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}
2578+#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}
2579+@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}}
2580+#social,#social-thread{margin:0 auto;width:100%;box-sizing:border-box}
2581+#social-head{padding-bottom:18px}#social-head h1{font-size:28px;letter-spacing:-.6px}
2582+#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)}
2583+#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)}
2584+[data-social-avatar]{width:36px;height:36px;border:0;background:none;line-height:36px}[data-social-avatar] img,[data-social-avatar]>span{display:block;width:36px;height:36px;border-radius:50%;background:var(--soft);object-fit:cover}
2585+[data-social-byline]>span:last-child{padding-left:11px}[data-social-byline] b{font-size:14px}[data-social-byline] small{display:inline-block;font-size:11px}
2586+[data-social-media] img,[data-social-media] video,[data-social-media] audio,[data-post-body] img,[data-comment-body] img{max-width:100%;height:auto;border-radius:8px}[data-social-media]{display:block;margin:14px 0}[data-social-media] video{display:block;max-height:520px;background:#000}[data-social-media] audio{width:100%}
2587+[data-social-actions]{line-height:2.4}[data-social-actions]>a{color:var(--muted)}[data-social-actions] button{min-height:32px;padding:2px 9px}details[data-social-react]{display:inline-block;position:relative;margin:0}details[data-social-react]>summary{display:inline-block;cursor:pointer;padding:0 6px}details[data-social-react][open]{background:var(--rail);padding:0 8px;border-radius:10px}
2588+[data-social-welcome]{margin:16px 0;color:var(--muted)}#social-empty h2{font-size:19px;font-weight:500;max-width:30ch}#social-empty p{max-width:48ch}
2589+social-compose input:not([type=file]),social-compose textarea{width:100%;box-sizing:border-box}social-compose textarea{border-radius:8px;padding:12px;min-height:80px}social-compose input[type=file]{max-width:100%;font-size:12px}social-compose[mode=article]>form>label:first-child input{font:24px/1.2 Georgia,serif;padding:10px}social-compose[mode=article] textarea{min-height:300px}
2590+[data-post-options]{margin:9px 0;font-size:12px;color:var(--muted)}[data-post-options] summary{cursor:pointer}[data-post-options][open]{padding:10px;background:var(--rail);border:1px solid var(--line);border-radius:8px}[data-compose-actions]{margin:12px 0 2px}[data-compose-actions]>button{border-radius:16px;padding:7px 14px}[data-compose-actions]>button[type=submit]{background:var(--accent);color:var(--paper);border-color:var(--accent)}[data-compose-actions]>small{display:inline-block;color:var(--muted);font-size:11px;margin-left:6px}[data-preview-output]{padding:16px;margin-top:12px;border:1px solid var(--line);border-radius:8px;overflow-wrap:anywhere}
2591+[data-social-back]{margin-bottom:18px;font-size:12px}#social-thread-post>li>[data-post-body=article]{font:19px/1.75 Georgia,serif;max-width:680px}#social-thread-post [data-post-body=article]>h1{font:600 38px/1.15 Georgia,serif;letter-spacing:-.8px;margin:18px 0 14px}#social-thread-post [data-post-summary]{font:18px/1.5 var(--sans);margin-bottom:20px}#social-thread-post [data-article-content]{margin-top:24px}#social-thread-post [data-article-content] h2{font:600 28px/1.2 Georgia,serif;margin:28px 0 12px}#social-thread-post [data-article-content] h3{font:600 23px/1.3 Georgia,serif;margin:24px 0 12px}#social-thread-post [data-article-content] p{margin-bottom:18px}
2592+@media(max-width:720px){#social-head h1{font-size:24px}#social-head p{font-size:13px}#social-feed>li{padding:19px 0}#social-feed>li[data-kind="30023"] h2{font-size:23px}[data-social-byline] time{font-size:10px}#social-thread-post [data-post-body=article]>h1{font-size:30px}#social-thread-post>li>[data-post-body=article]{font-size:17px}[data-compose-actions]>small{display:block;margin:7px 0 0}[data-social-actions]>*{margin-right:7px}}
2593+
2594+[data-social-parent]{display:block;margin-top:3px}[data-social-parent] a{color:var(--muted)}[data-thread-context]{font-size:12px}[data-thread-context] a{margin-right:18px}#social-comments li[data-nested-reply]{padding-left:20px;border-left:2px solid var(--line);margin-left:16px}
2595+
2596+[data-social-avatar]{min-width:36px}[data-social-avatar]>nostr-avatar{display:block;width:36px;height:36px}#social-comments h2 small{margin-left:5px;font-weight:400;color:var(--muted)}
2597+
2598+[data-reaction-emoji]{display:inline-block;width:20px;height:20px;object-fit:contain;vertical-align:middle}
2599+[data-post-body=note]>img,[data-comment-body]>img{display:block;width:auto;max-height:560px;margin:12px 0}
2600diff --git a/internal/webui/templates.go b/internal/webui/templates.go
2601index bf44417..2819e93 100644
2602--- a/internal/webui/templates.go
2603+++ b/internal/webui/templates.go
2604@@ -16,7 +16,7 @@ import (
2605 // templateFS holds every page template. page.html owns the shell and the
2606 // shared partials; the other files each define one group of tabs.
2607 //
2608-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html
2609+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html rooms.html social.html
2610 var templateFS embed.FS
2611
2612 // styleCSS is inlined into every page so the UI needs no extra request and
2613@@ -114,7 +114,7 @@ type navItem struct {
2614
2615 // relayNav: entry points, then conversation, then collaborative artifacts,
2616 // then what the relay publishes and stores, then what is yours.
2617-var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/rooms", "/rooms", "rooms", 1}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
2618+var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/rooms", "/rooms", "rooms", 1}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/social", "/social", "social", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
2619
2620 // manageNav: who is here, what they may do, what the relay is, what it does
2621 // over time, and how it is doing.
2622@@ -183,7 +183,7 @@ func pageAddress(base, path string, query url.Values) string {
2623 path = "/wiki/" + url.PathEscape(wikiPageName(path))
2624 }
2625 keep := url.Values{}
2626- for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge"} {
2627+ for _, key := range []string{"owner", "repo", "view", "path", "ref", "sha", "hash", "q", "id", "author", "version", "merge", "address", "kind"} {
2628 if value := query.Get(key); value != "" {
2629 keep.Set(key, value)
2630 }
2631@@ -259,79 +259,89 @@ func parseTemplates(a *App) (*template.Template, error) {
2632 }
2633 return string(encoded)
2634 },
2635- "join": strings.Join,
2636- "urlquery": url.QueryEscape,
2637- "asMap": valueMap,
2638- "str": plainString,
2639- "datetime": datetime,
2640- "when": when,
2641- "fileSize": fileSize,
2642- "markdown": a.markdown,
2643- "chatMarkdown": a.chatMarkdown,
2644- "hasPrefix": strings.HasPrefix,
2645- "npub": identityNpub,
2646- "wsURL": wsURL,
2647- "short": short,
2648- "prompt": promptPath,
2649- "pageAddress": pageAddress,
2650- "signinURL": signinURL,
2651- "railKind": railKind,
2652- "repoView": repoView,
2653- "relayItems": func() []navItem { return relayNav },
2654- "manageItems": func() []navItem { return manageNav },
2655- "navGroups": navGroups,
2656- "add": func(a, b int) int { return a + b },
2657- "strs": func(values ...string) []string { return values },
2658- "repoCommitURL": repoCommitURL,
2659- "hasNextOffset": hasNextOffset,
2660- "repoURL": repoURL,
2661- "repoBreadcrumbs": repoBreadcrumbs,
2662- "browsePageURL": browsePageURL,
2663- "shortID": shortID,
2664- "repoDate": repoDate,
2665- "sourceHTML": sourceHTML,
2666- "diffHTML": diffHTML,
2667- "collabItems": collaborationItems,
2668- "collabReplies": collaborationReplies,
2669- "collabLabels": collaborationLabels,
2670- "collabProposal": collaborationProposal,
2671- "reviewDiff": reviewDiffView,
2672- "reviewAnchor": reviewAnchorQuery,
2673- "reviewLabel": reviewAnchorLabel,
2674- "agentState": agentState,
2675- "agentSince": agentSince,
2676- "agentScope": agentScope,
2677- "agentLabel": agentLabel,
2678- "agentCounts": agentCounts,
2679- "pendingRequests": pendingRequests,
2680- "decidedRequests": decidedRequests,
2681- "callbacksFor": callbacksFor,
2682- "callbackState": callbackState,
2683- "callbackKinds": callbackKinds,
2684- "callbackCounts": callbackCounts,
2685- "scopeList": scopeList,
2686- "dateAfter": dateAfter,
2687- "agentByKey": agentByKey,
2688- "repoLines": repoLines,
2689- "siteLines": siteLines,
2690- "dateOf": dateOf,
2691- "approvalViews": approvalViews,
2692- "approvalCounts": approvalCounts,
2693- "approvalDevices": approvalDevices,
2694- "wikiHTML": a.wikiHTML,
2695- "viewState": customViewState,
2696- "viewKinds": customViewKinds,
2697- "viewLanguages": customViewLanguages,
2698- "wikiView": wikiPageView,
2699- "wikiProposal": wikiProposal,
2700- "wikiURL": wikiURL,
2701- "roomItems": roomItems,
2702- "roomRoot": roomRoot,
2703- "roomMembers": roomMembers,
2704- "roomContent": roomContent,
2705- "age": age,
2706- "clock": clock,
2707- "roomAdmin": roomAdmin,
2708+ "join": strings.Join,
2709+ "urlquery": url.QueryEscape,
2710+ "asMap": valueMap,
2711+ "str": plainString,
2712+ "datetime": datetime,
2713+ "when": when,
2714+ "fileSize": fileSize,
2715+ "socialThreadPage": socialThreadPage,
2716+ "socialReactionCount": socialReactionCount,
2717+ "socialReacted": socialReacted,
2718+ "socialContext": socialContext,
2719+ "socialQuery": socialQuery,
2720+ "socialURL": socialURL,
2721+ "socialThreadURL": socialURL,
2722+ "socialBody": a.socialBody,
2723+ "socialPreview": socialPreview,
2724+ "socialMediaURL": socialMediaURL,
2725+ "markdown": a.markdown,
2726+ "chatMarkdown": a.chatMarkdown,
2727+ "hasPrefix": strings.HasPrefix,
2728+ "npub": identityNpub,
2729+ "wsURL": wsURL,
2730+ "short": short,
2731+ "prompt": promptPath,
2732+ "pageAddress": pageAddress,
2733+ "signinURL": signinURL,
2734+ "railKind": railKind,
2735+ "repoView": repoView,
2736+ "relayItems": func() []navItem { return relayNav },
2737+ "manageItems": func() []navItem { return manageNav },
2738+ "navGroups": navGroups,
2739+ "add": func(a, b int) int { return a + b },
2740+ "strs": func(values ...string) []string { return values },
2741+ "repoCommitURL": repoCommitURL,
2742+ "hasNextOffset": hasNextOffset,
2743+ "repoURL": repoURL,
2744+ "repoBreadcrumbs": repoBreadcrumbs,
2745+ "browsePageURL": browsePageURL,
2746+ "shortID": shortID,
2747+ "repoDate": repoDate,
2748+ "sourceHTML": sourceHTML,
2749+ "diffHTML": diffHTML,
2750+ "collabItems": collaborationItems,
2751+ "collabReplies": collaborationReplies,
2752+ "collabLabels": collaborationLabels,
2753+ "collabProposal": collaborationProposal,
2754+ "reviewDiff": reviewDiffView,
2755+ "reviewAnchor": reviewAnchorQuery,
2756+ "reviewLabel": reviewAnchorLabel,
2757+ "agentState": agentState,
2758+ "agentSince": agentSince,
2759+ "agentScope": agentScope,
2760+ "agentLabel": agentLabel,
2761+ "agentCounts": agentCounts,
2762+ "pendingRequests": pendingRequests,
2763+ "decidedRequests": decidedRequests,
2764+ "callbacksFor": callbacksFor,
2765+ "callbackState": callbackState,
2766+ "callbackKinds": callbackKinds,
2767+ "callbackCounts": callbackCounts,
2768+ "scopeList": scopeList,
2769+ "dateAfter": dateAfter,
2770+ "agentByKey": agentByKey,
2771+ "repoLines": repoLines,
2772+ "siteLines": siteLines,
2773+ "dateOf": dateOf,
2774+ "approvalViews": approvalViews,
2775+ "approvalCounts": approvalCounts,
2776+ "approvalDevices": approvalDevices,
2777+ "wikiHTML": a.wikiHTML,
2778+ "viewState": customViewState,
2779+ "viewKinds": customViewKinds,
2780+ "viewLanguages": customViewLanguages,
2781+ "wikiView": wikiPageView,
2782+ "wikiProposal": wikiProposal,
2783+ "wikiURL": wikiURL,
2784+ "roomItems": roomItems,
2785+ "roomRoot": roomRoot,
2786+ "roomMembers": roomMembers,
2787+ "roomContent": roomContent,
2788+ "age": age,
2789+ "clock": clock,
2790+ "roomAdmin": roomAdmin,
2791
2792 "roomAttachmentContent": roomAttachmentContent,
2793 "roomAttachmentMarkup": roomAttachmentMarkup,
2794diff --git a/internal/webui/webui.go b/internal/webui/webui.go
2795index 263ba53..31ddb9c 100644
2796--- a/internal/webui/webui.go
2797+++ b/internal/webui/webui.go
2798@@ -179,6 +179,9 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
2799 if a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {
2800 return
2801 }
2802+ if a.handleSocialRoute(writer, request) {
2803+ return
2804+ }
2805 if request.Method != http.MethodGet && request.Method != http.MethodHead {
2806 http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
2807 return
2808@@ -442,6 +445,9 @@ func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
2809 if address {
2810 identifier = strings.TrimPrefix(request.URL.Path, "/a/")
2811 }
2812+ if a.socialReferencePage(writer, request, identifier, address, actor) {
2813+ return
2814+ }
2815 method := "eventdetail"
2816 params := []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q`, identifier))}
2817 if address {
2818diff --git a/scripts/social-compose.test.mjs b/scripts/social-compose.test.mjs
2819new file mode 100644
2820index 0000000..098a3fa
2821--- /dev/null
2822+++ b/scripts/social-compose.test.mjs
2823@@ -0,0 +1,118 @@
2824+import test from "node:test";
2825+import assert from "node:assert/strict";
2826+import fs from "node:fs";
2827+import vm from "node:vm";
2828+import {webcrypto,createHash} from "node:crypto";
2829+
2830+const source=fs.readFileSync("internal/webui/components.js","utf8");
2831+const code=source.slice(source.indexOf(" // Social follows"),source.indexOf(" // Social end."));
2832+const author="a".repeat(64),parentAuthor="b".repeat(64),root="c".repeat(64),parent="d".repeat(64),hint="wss://relay.test";
2833+function setup(attrs={},values={},options={}) {
2834+ const attributes={actor:author,relay:hint,...attrs},store=options.store||new Map(),calls=[],signed=[];
2835+ let now=1789000000,fail=options.fail||0;
2836+ class FormElement {getAttribute(n){return attributes[n]??null;}report(message){this.message=message;}}
2837+ class HTMLElement {}
2838+ const fields=Object.fromEntries(Object.entries(values).map(([name,value])=>[name, typeof value === "object" ? value : {value}]));
2839+ const form={elements:fields,reset(){for(const field of Object.values(fields))if("value"in field)field.value="";}};
2840+ const tiny={root:"",localPath:path=>path,sha256hex:async data=>createHash("sha256").update(data).digest("hex"),
2841+ signing:{signEvent:async unsigned=>{signed.push(unsigned);return {...unsigned,id:"e".repeat(64),pubkey:author};}},
2842+ signedFetch:async(path,method,body)=>{calls.push({path,method,body});if(path === "/social/preview") return {ok:true,json:async()=>({html:"<p>Preview</p>"})};if(path.startsWith("/upload")){return {ok:true,json:async()=>({sha256:await tiny.sha256hex(body),size:body.length,url:"https://relay.test/file.jpg"})};}if(fail-->0)return {ok:false,json:async()=>({error:"interrupted"})};return {ok:true,json:async()=>({accepted:true})};},
2843+ navigate:async href=>{tiny.href=href;}
2844+ };
2845+ const ctx={FormElement,HTMLElement,tiny,URL,Uint8Array,WeakMap,crypto:webcrypto,location:{origin:"https://relay.test",pathname:"/social",href:"https://relay.test/social"},localStorage:{getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)},isHex64:v=>typeof v==="string"&&/^[0-9a-f]{64}$/.test(v),unixNow:()=>now,el:()=>({setAttribute(){}}),fetch:options.fetch};
2846+ const classes=vm.runInNewContext(code+"\n({SocialCompose,SocialReaction,socialDraftKey})",ctx);
2847+ const component=new classes.SocialCompose();component.form=form;
2848+ return {component,form,calls,signed,tiny,store,attrs:attributes,classes,advance(){now+=90;},tags(){return JSON.parse(JSON.stringify(signed.at(-1).tags));}};
2849+}
2850+
2851+test("notes keep plaintext and topics normalized",async()=>{
2852+ const s=setup({mode:"note"},{content:"**literal**\nTwo lines",tags:"#Nostr, nostr, NEWS"});
2853+ await s.component.submit(s.form);
2854+ assert.equal(s.signed[0].kind,1);assert.equal(s.signed[0].content,"**literal**\nTwo lines");
2855+ assert.deepEqual(s.tags(),[["t","nostr"],["t","news"]]);
2856+});
2857+
2858+test("new articles with identical titles have distinct persistent addresses",async()=>{
2859+ const s=setup({mode:"article"},{title:"Same title",content:"# Markdown",summary:"Subtitle",cover:"https://cdn.test/a.png"});
2860+ const other=setup({mode:"article"},{title:"Same title",content:"Another story"});
2861+ await s.component.submit(s.form);await other.component.submit(other.form);
2862+ const tags=s.tags();assert.notEqual(tags.find(t=>t[0]==="d")[1],other.tags().find(t=>t[0]==="d")[1]);
2863+ assert.equal(s.signed[0].kind,30023);assert.ok(tags.some(t=>t[0]==="title"&&t[1]==="Same title"));assert.ok(tags.some(t=>t[0]==="published_at"));
2864+});
2865+
2866+test("NIP-10 top-level reply uses one root marker and inherited p tags",async()=>{
2867+ const s=setup({mode:"comment",target:root,"target-kind":"1","target-pubkey":author,"target-tags":JSON.stringify([["p",parentAuthor]])},{content:"Reply"});
2868+ await s.component.submit(s.form);
2869+ assert.deepEqual(s.tags(),[["e",root,hint,"root",author],["p",author,hint],["p",parentAuthor,hint]]);
2870+});
2871+
2872+test("NIP-10 nested replies distinguish root and direct parent",async()=>{
2873+ const s=setup({mode:"comment",target:parent,"target-kind":"1","target-pubkey":parentAuthor,root,"root-pubkey":author},{content:"Nested"});
2874+ await s.component.submit(s.form);assert.deepEqual(s.tags(),[["e",root,hint,"root",author],["e",parent,hint,"reply",parentAuthor],["p",author,hint],["p",parentAuthor,hint]]);
2875+});
2876+
2877+test("NIP-22 article comment has address scope and address plus id parent",async()=>{
2878+ const address=`30023:${author}:essay:part-one`;
2879+ const s=setup({mode:"comment",target:root,"target-kind":"30023","target-pubkey":author,address},{content:"Article comment"});
2880+ await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111);
2881+ assert.deepEqual(s.tags(),[["A",address,hint],["K","30023"],["P",author,hint],["a",address,hint],["e",root,hint,author],["k","30023"],["p",author,hint]]);
2882+});
2883+
2884+test("NIP-22 nested comment keeps root scope but direct parent's author and kind",async()=>{
2885+ const address=`30023:${author}:essay`;
2886+ const s=setup({mode:"comment",target:parent,"target-kind":"1111","target-pubkey":parentAuthor,root,"root-pubkey":author,address},{content:"Nested article comment"});
2887+ await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111);
2888+ assert.deepEqual(s.tags(),[["A",address,hint],["K","30023"],["P",author,hint],["e",parent,hint,parentAuthor],["k","1111"],["p",parentAuthor,hint]]);
2889+});
2890+
2891+test("relay failure retains signed event across a later retry",async()=>{
2892+ const s=setup({mode:"note"},{content:"Retry"},{fail:1});
2893+ await assert.rejects(s.component.submit(s.form),/interrupted/);s.advance();await s.component.submit(s.form);
2894+ assert.equal(s.signed.length,1);assert.equal(s.calls.length,2);assert.equal(s.calls[0].body,s.calls[1].body);
2895+});
2896+
2897+test("editing a failed post requires a fresh signature",async()=>{
2898+ const s=setup({mode:"note"},{content:"Before"},{fail:1});
2899+ await assert.rejects(s.component.submit(s.form));s.form.elements.content.value="After";await s.component.submit(s.form);assert.equal(s.signed.length,2);
2900+});
2901+
2902+test("duplicate submits publish only once",async()=>{
2903+ const s=setup({mode:"note"},{content:"One"});await Promise.all([s.component.submit(s.form),s.component.submit(s.form)]);assert.equal(s.signed.length,1);assert.equal(s.calls.length,1);
2904+});
2905+
2906+test("drafts remain scoped to account and article and clear when erased",()=>{
2907+ const s=setup({mode:"article"},{title:"Story",content:"private draft"});s.component.saveDraft();
2908+ const other=setup({mode:"article",actor:parentAuthor},{title:"",content:""},{store:s.store});other.component.restoreDraft();assert.equal(other.form.elements.content.value,"");
2909+ const same=setup({mode:"article"},{title:"",content:""},{store:s.store});same.component.restoreDraft();assert.equal(same.form.elements.content.value,"private draft");
2910+ s.form.elements.title.value="";s.form.elements.content.value="";s.component.saveDraft();assert.equal(s.store.size,0);
2911+});
2912+
2913+test("media is uploaded once on retry and notes embed raw URLs with imeta",async()=>{
2914+ const file={name:"photo.jpg",type:"image/jpeg",size:3,arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer};
2915+ const s=setup({mode:"note"},{content:"Photo",alt:"A mountain", "media-file":{files:[file]}},{fail:1});
2916+ await assert.rejects(s.component.submit(s.form),/interrupted/);await s.component.submit(s.form);
2917+ assert.equal(s.calls.filter(c=>c.path.startsWith("/upload")).length,1);
2918+ assert.equal(s.signed[0].content,"Photo\n\nhttps://relay.test/file.jpg");
2919+ const imeta=s.tags().find(t=>t[0]==="imeta");assert.ok(imeta.includes("m image/jpeg"));assert.ok(imeta.includes("alt A mountain"));assert.ok(imeta.includes("size 3"));
2920+});
2921+
2922+test("unsafe media and cover URLs are rejected before publishing",async()=>{
2923+ for(const values of [{content:"note",media:"javascript:alert(1)"},{content:"note",media:"https://secret@example.com/a"}]){const s=setup({mode:"note"},values);await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);}
2924+ const s=setup({mode:"article"},{title:"Title",content:"Body",cover:"data:image/svg+xml,bad"});await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);
2925+});
2926+
2927+
2928+test("draft previews use authenticated requests without publishing", async()=>{
2929+ const s=setup({mode:"article"},{content:"**Preview**"});
2930+ const output={hidden:true,innerHTML:""};s.component.querySelector=()=>output;
2931+ await s.component.preview();
2932+ assert.equal(s.calls[0].path,"/social/preview");assert.equal(s.calls[0].method,"POST");
2933+ assert.deepEqual(JSON.parse(s.calls[0].body),{content:"**Preview**",kind:30023});
2934+ assert.equal(s.signed.length,0);assert.equal(output.hidden,false);assert.equal(output.innerHTML,"<p>Preview</p>");
2935+});
2936+
2937+test("legacy replies omit an unknown optional root author rather than misattribute it", async()=>{
2938+ const s=setup({mode:"comment",target:parent,"target-kind":"1","target-pubkey":parentAuthor,root,"root-pubkey":""},{content:"Legacy reply"});
2939+ await s.component.submit(s.form);
2940+ assert.deepEqual(s.tags(),[["e",root,hint,"root"],["e",parent,hint,"reply",parentAuthor],["p",parentAuthor,hint]]);
2941+});

About

ownerc01503e8035f
visibilitypublic
head1944aa061621

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs