| 1 | diff --git a/README.md b/README.md |
| 2 | index 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. |
| 14 | diff --git a/docs/social.md b/docs/social.md |
| 15 | new file mode 100644 |
| 16 | index 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. |
| 57 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 58 | index 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 { |
| 80 | diff --git a/internal/daemon/social_browse.go b/internal/daemon/social_browse.go |
| 81 | new file mode 100644 |
| 82 | index 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 | +} |
| 682 | diff --git a/internal/daemon/social_browse_test.go b/internal/daemon/social_browse_test.go |
| 683 | new file mode 100644 |
| 684 | index 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 | +} |
| 825 | diff --git a/internal/daemon/social_protocol_test.go b/internal/daemon/social_protocol_test.go |
| 826 | new file mode 100644 |
| 827 | index 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 | +} |
| 903 | diff --git a/internal/daemon/social_reactions_test.go b/internal/daemon/social_reactions_test.go |
| 904 | new file mode 100644 |
| 905 | index 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 | +} |
| 966 | diff --git a/internal/daemon/social_regression_test.go b/internal/daemon/social_regression_test.go |
| 967 | new file mode 100644 |
| 968 | index 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 | +} |
| 1102 | diff --git a/internal/templates/templates.go b/internal/templates/templates.go |
| 1103 | index 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"}}, |
| 1114 | diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go |
| 1115 | index 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 | +} |
| 1161 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 1162 | index 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; |
| 1396 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 1397 | index 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" .}} |
| 1418 | diff --git a/internal/webui/public_pages.go b/internal/webui/public_pages.go |
| 1419 | index 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>`) |
| 1457 | diff --git a/internal/webui/public_pages_test.go b/internal/webui/public_pages_test.go |
| 1458 | index 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() |
| 1470 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 1471 | index 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 { |
| 1483 | diff --git a/internal/webui/social.html b/internal/webui/social.html |
| 1484 | new file mode 100644 |
| 1485 | index 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}} |
| 1558 | diff --git a/internal/webui/social_content.go b/internal/webui/social_content.go |
| 1559 | new file mode 100644 |
| 1560 | index 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 | +} |
| 1849 | diff --git a/internal/webui/social_content_test.go b/internal/webui/social_content_test.go |
| 1850 | new file mode 100644 |
| 1851 | index 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", |
| 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": "``\n\n````\n\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": "\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"})) |
| 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 | +} |
| 1976 | diff --git a/internal/webui/social_mentions.go b/internal/webui/social_mentions.go |
| 1977 | new file mode 100644 |
| 1978 | index 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 | +} |
| 2054 | diff --git a/internal/webui/social_mentions_test.go b/internal/webui/social_mentions_test.go |
| 2055 | new file mode 100644 |
| 2056 | index 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 | +} |
| 2102 | diff --git a/internal/webui/social_pages.go b/internal/webui/social_pages.go |
| 2103 | new file mode 100644 |
| 2104 | index 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 | +} |
| 2331 | diff --git a/internal/webui/social_pages_test.go b/internal/webui/social_pages_test.go |
| 2332 | new file mode 100644 |
| 2333 | index 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 | +} |
| 2475 | diff --git a/internal/webui/social_reference_routes.go b/internal/webui/social_reference_routes.go |
| 2476 | new file mode 100644 |
| 2477 | index 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 | +} |
| 2540 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 2541 | index 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} |
| 2600 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 2601 | index 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, |
| 2794 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 2795 | index 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 { |
| 2818 | diff --git a/scripts/social-compose.test.mjs b/scripts/social-compose.test.mjs |
| 2819 | new file mode 100644 |
| 2820 | index 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 | +}); |