clone

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

feat/ui-updates
tinyrelay / internal / daemon / social_browse.go
social_browse.go 23.2K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "net/url"9 "sort"10 "strconv"11 "strings"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/event"15 "github.com/FelineStateMachine/tinyrelay/internal/podcasts"16 "github.com/FelineStateMachine/tinyrelay/internal/storage"17)1819type socialBrowseRequest struct {20 Kind string `json:"kind"`21 Author string `json:"author"`22 Query string `json:"q"`23 Cursor string `json:"cursor"`24 Limit int `json:"limit"`25 ID string `json:"id"`26 Address string `json:"address"`27}2829type socialItem struct {30 ID string `json:"id"`31 Kind int `json:"kind"`32 Author string `json:"author"`33 Pubkey string `json:"pubkey"`34 Tags [][]string `json:"tags,omitempty"`35 CreatedAt int64 `json:"created_at"`36 Content string `json:"content"`37 Title string `json:"title,omitempty"`38 Summary string `json:"summary,omitempty"`39 Image string `json:"image,omitempty"`40 Address string `json:"address,omitempty"`41 URL string `json:"url,omitempty"`42 ReadPath string `json:"read_path"`43 AuthorName string `json:"author_name,omitempty"`44 AuthorPicture string `json:"author_picture,omitempty"`45 ReplyCount int `json:"reply_count"`46 Reactions []socialReaction `json:"reactions,omitempty"`47 ParentID string `json:"parent_id,omitempty"`48 ParentAuthor string `json:"parent_author,omitempty"`49 ParentKind int `json:"parent_kind,omitempty"`50 ParentAddress string `json:"parent_address,omitempty"`51 RootID string `json:"root_id,omitempty"`52 RootAuthor string `json:"root_author,omitempty"`53 RootKind int `json:"root_kind,omitempty"`54 RootAddress string `json:"root_address,omitempty"`55 ReadMinutes int `json:"read_minutes,omitempty"`56 Event event.Event `json:"event"`57 Podcast *podcasts.Show `json:"podcast,omitempty"`58}5960type socialReaction struct {61 Content string `json:"content"`62 Count int `json:"count"`63 Reacted bool `json:"reacted,omitempty"`64 Image string `json:"image,omitempty"`65}6667func socialBrowseMethod(method string) bool {68 return method == "browsesocial" || method == "browsesocialthread" || method == "browsepodcasts"69}7071func (t *Tenant) executeSocial(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {72 if !t.Policy().Features.Pages {73 return nil, errors.New("not found: social is disabled")74 }75 q := socialBrowseRequest{}76 if len(params) > 0 {77 if err := json.Unmarshal(params[0], &q); err != nil {78 return nil, fmt.Errorf("invalid: social parameters: %w", err)79 }80 }81 if q.Limit <= 0 || q.Limit > 100 {82 q.Limit = 3083 }84 if q.Author != "" && !socialHex(q.Author) {85 return nil, errors.New("invalid: social author")86 }87 if method == "browsesocialthread" {88 return t.browseSocialThread(ctx, actor, q)89 }90 if method == "browsepodcasts" || q.Kind == "podcasts" {91 return t.browsePodcasts(ctx, actor, q)92 }93 return t.browseSocial(ctx, actor, q)94}9596func socialHex(value string) bool {97 return len(value) == 64 && strings.Trim(value, "0123456789abcdef") == ""98}99func socialAddress(row event.Event) string {100 if row.Kind == 30023 {101 return "30023:" + row.PubKey + ":" + event.Tag(row, "d")102 }103 return ""104}105func socialCursor(row event.Event) string {106 return collaborationCursor(collaborationItem{CreatedAt: row.CreatedAt, ID: row.ID})107}108func socialOlder(a, b event.Event) bool {109 if a.CreatedAt == b.CreatedAt {110 return a.ID < b.ID111 }112 return a.CreatedAt > b.CreatedAt113}114115func (t *Tenant) browseSocial(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {116 kinds := []int{1, 30023, podcasts.KindEpisode}117 switch q.Kind {118 case "", "all", "feed":119 case "notes":120 kinds = []int{1}121 case "articles", "posts":122 kinds = []int{30023}123 case "photos", "videos":124 default:125 return nil, errors.New("invalid: social kind")126 }127 cursor, err := parseCollaborationCursor(q.Cursor)128 if err != nil {129 return nil, err130 }131 filter := event.Filter{Kinds: kinds}132 if q.Author != "" {133 filter.Authors = []string{q.Author}134 }135 rows := []event.Event{}136 next := ""137 session := browseSession(t, actor)138 for scan := 0; scan < 20; scan++ {139 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})140 if err != nil {141 return nil, err142 }143 for _, row := range page.Events {144 cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}145 if socialFeedRoot(row) && t.gate.CanSee(ctx, row, session, &filter) && socialCategoryMatches(row, q.Kind) && socialMatches(row, q.Query) {146 rows = append(rows, row)147 }148 if len(rows) > q.Limit {149 break150 }151 }152 if len(rows) > q.Limit {153 next = socialCursor(rows[q.Limit-1])154 rows = rows[:q.Limit]155 break156 }157 if !page.More || len(page.Events) == 0 {158 break159 }160 if scan == 19 && cursor != nil {161 next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})162 }163 }164 items, err := t.socialItems(ctx, actor, rows)165 if err != nil {166 return nil, err167 }168 if err := t.decoratePodcastItems(ctx, actor, items); err != nil {169 return nil, err170 }171 stats := map[string]int{"notes": 0, "articles": 0, "comments": 0}172 for _, item := range items {173 if item.Kind == 30023 {174 stats["articles"]++175 } else if item.Kind == podcasts.KindEpisode {176 stats["podcasts"]++177 } else {178 stats["notes"]++179 }180 stats["comments"] += item.ReplyCount181 }182 return map[string]any{"items": items, "next_cursor": next, "stats": stats}, nil183}184185func (t *Tenant) decoratePodcastItems(ctx context.Context, actor string, items []socialItem) error {186 authors := make([]string, 0)187 seen := map[string]bool{}188 for _, item := range items {189 if item.Kind == podcasts.KindEpisode && !seen[item.Pubkey] {190 seen[item.Pubkey] = true191 authors = append(authors, item.Pubkey)192 }193 }194 if len(authors) == 0 {195 return nil196 }197 shows, err := t.socialShows(ctx, actor, authors)198 if err != nil {199 return err200 }201 for i := range items {202 if items[i].Kind != podcasts.KindEpisode {203 continue204 }205 show := shows[items[i].Pubkey]206 items[i].Podcast = &show207 if show.Title != "" {208 items[i].AuthorName, items[i].AuthorPicture = show.Title, show.Image209 }210 }211 return nil212}213214func (t *Tenant) browsePodcasts(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {215 cursor, err := parseCollaborationCursor(q.Cursor)216 if err != nil {217 return nil, err218 }219 filter := event.Filter{Kinds: []int{podcasts.KindEpisode}}220 if q.Author != "" {221 filter.Authors = []string{q.Author}222 }223 session := browseSession(t, actor)224 next := ""225 rows := make([]event.Event, 0, q.Limit)226 for scan := 0; scan < 20 && len(rows) <= q.Limit; scan++ {227 page, queryErr := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})228 if queryErr != nil {229 return nil, queryErr230 }231 for _, row := range page.Events {232 cursor = &storage.EventCursor{CreatedAt: row.CreatedAt, ID: row.ID}233 if t.gate.CanSee(ctx, row, session, &filter) {234 if _, ok := podcasts.ParseEpisode(row); ok && socialMatches(row, q.Query) {235 rows = append(rows, row)236 }237 }238 if len(rows) > q.Limit {239 break240 }241 }242 if len(rows) > q.Limit {243 next = socialCursor(rows[q.Limit-1])244 rows = rows[:q.Limit]245 break246 }247 if !page.More || len(page.Events) == 0 {248 break249 }250 if scan == 19 && cursor != nil {251 next = collaborationCursor(collaborationItem{CreatedAt: cursor.CreatedAt, ID: cursor.ID})252 }253 }254 items, err := t.socialItems(ctx, actor, rows)255 if err != nil {256 return nil, err257 }258 keys := make([]string, 0, len(rows))259 seen := map[string]bool{}260 for _, row := range rows {261 if !seen[row.PubKey] {262 seen[row.PubKey] = true263 keys = append(keys, row.PubKey)264 }265 }266 if q.Author != "" && !seen[q.Author] {267 keys = append(keys, q.Author)268 }269 shows, err := t.socialShows(ctx, actor, keys)270 if err != nil {271 return nil, err272 }273 for i := range items {274 show := shows[items[i].Pubkey]275 items[i].Podcast = &show276 if show.Title != "" {277 items[i].AuthorName, items[i].AuthorPicture = show.Title, show.Image278 }279 }280 result := map[string]any{"items": items, "next_cursor": next, "shows": shows}281 if q.Author != "" {282 show := shows[q.Author]283 result["show"] = show284 }285 return result, nil286}287288func (t *Tenant) socialShows(ctx context.Context, actor string, authors []string) (map[string]podcasts.Show, error) {289 if len(authors) == 0 {290 return map[string]podcasts.Show{}, nil291 }292 metadataFilter := event.Filter{Kinds: []int{podcasts.KindMetadata}, Authors: authors}293 metadata, err := t.socialEvents(ctx, actor, []event.Filter{metadataFilter})294 if err != nil {295 return nil, err296 }297 declared := map[string]bool{}298 for _, row := range metadata {299 for _, tag := range row.Tags {300 if len(tag) > 1 && tag[0] == "p" {301 declared[tag[1]] = true302 }303 }304 }305 if len(declared) == 0 {306 shows := podcasts.Shows(metadata)307 for _, author := range authors {308 if _, ok := shows[author]; !ok {309 shows[author] = podcasts.Show{Pubkey: author}310 }311 }312 return shows, nil313 }314 authorKeys := make([]string, 0, len(declared))315 for key := range declared {316 authorKeys = append(authorKeys, key)317 }318 authored, err := t.socialEvents(ctx, actor, []event.Filter{{Kinds: []int{podcasts.KindAuthored}, Authors: authorKeys}})319 if err != nil {320 return nil, err321 }322 rows := append(metadata, authored...)323 shows := podcasts.Shows(rows)324 for _, author := range authors {325 if _, ok := shows[author]; !ok {326 shows[author] = podcasts.Show{Pubkey: author}327 }328 }329 return shows, nil330}331332func socialFeedRoot(row event.Event) bool {333 if row.Kind == 30023 {334 return true335 }336 if row.Kind == podcasts.KindEpisode {337 _, ok := podcasts.ParseEpisode(row)338 return ok && event.Tag(row, "h") == ""339 }340 if row.Kind != 1 || event.Tag(row, "h") != "" {341 return false342 }343 ref := socialReference(row)344 return ref.parent == "" && ref.parentAddress == ""345}346func socialMatches(row event.Event, query string) bool {347 needle := strings.ToLower(strings.TrimSpace(query))348 return needle == "" || strings.Contains(strings.ToLower(row.Content+" "+event.Tag(row, "title")+" "+event.Tag(row, "summary")+" "+event.Tag(row, "description")), needle)349}350351// socialEvents scans only the requested references and applies the same per-event352// visibility rules as relay subscriptions, including profiles and reactions.353func (t *Tenant) socialEvents(ctx context.Context, actor string, filters []event.Filter) ([]event.Event, error) {354 session := browseSession(t, actor)355 out := []event.Event{}356 seen := map[string]bool{}357 for _, filter := range filters {358 var cursor *storage.EventCursor359 for {360 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 200, Before: cursor})361 if err != nil {362 return nil, err363 }364 for _, row := range page.Events {365 if !seen[row.ID] && t.gate.CanSee(ctx, row, session, &filter) {366 seen[row.ID] = true367 out = append(out, row)368 }369 }370 if !page.More || len(page.Events) == 0 {371 break372 }373 last := page.Events[len(page.Events)-1]374 cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}375 }376 }377 sort.Slice(out, func(i, j int) bool { return socialOlder(out[i], out[j]) })378 return out, nil379}380func socialActivityFilters(rows []event.Event) []event.Filter {381 ids, addresses := []string{}, []string{}382 for _, row := range rows {383 ids = append(ids, row.ID)384 if address := socialAddress(row); address != "" {385 addresses = append(addresses, address)386 }387 }388 filters := []event.Filter{{Kinds: []int{1, 7, 1111}, Tags: map[string][]string{"e": ids}}, {Kinds: []int{1111}, Tags: map[string][]string{"E": ids}}}389 if len(addresses) > 0 {390 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}})391 }392 return filters393}394func (t *Tenant) socialItems(ctx context.Context, actor string, rows []event.Event) ([]socialItem, error) {395 if len(rows) == 0 {396 return []socialItem{}, nil397 }398 extra, err := t.socialEvents(ctx, actor, socialActivityFilters(rows))399 if err != nil {400 return nil, err401 }402 items := socialItemsWithRefs(rows, extra, actor, nil)403 authors := []string{}404 seen := map[string]bool{}405 for _, row := range rows {406 if !seen[row.PubKey] {407 seen[row.PubKey] = true408 authors = append(authors, row.PubKey)409 }410 }411 profiles, err := t.socialEvents(ctx, actor, []event.Filter{{Kinds: []int{0}, Authors: authors}})412 if err != nil {413 return nil, err414 }415 names := map[string][2]string{}416 for _, profile := range profiles {417 var p struct {418 Name string `json:"name"`419 Display string `json:"display_name"`420 Picture string `json:"picture"`421 }422 if json.Unmarshal([]byte(profile.Content), &p) == nil {423 if p.Display == "" {424 p.Display = p.Name425 }426 names[profile.PubKey] = [2]string{p.Display, p.Picture}427 }428 }429 for i := range items {430 items[i].AuthorName, items[i].AuthorPicture = names[items[i].Pubkey][0], names[items[i].Pubkey][1]431 }432 if err := t.decoratePodcastItems(ctx, actor, items); err != nil {433 return nil, err434 }435 return items, nil436}437438func socialItemsWithRefs(rows, extra []event.Event, actor string, _ map[string]string) []socialItem {439 items := make([]socialItem, len(rows))440 index := map[string]int{}441 for i, row := range rows {442 items[i] = socialItemFrom(row)443 index[row.ID] = i444 if a := socialAddress(row); a != "" {445 index[a] = i446 }447 }448 type reactionKey struct {449 index int450 author, content string451 }452 seen := map[reactionKey]bool{}453 counts := map[int]map[string]socialReaction{}454 for _, row := range extra {455 if row.Kind == 7 {456 ref := socialReference(row)457 target := ref.parent458 if _, ok := index[ref.parentAddress]; ok {459 target = ref.parentAddress460 }461 i, ok := index[target]462 if !ok {463 continue464 }465 content, image := socialReactionValue(row)466 key := reactionKey{i, row.PubKey, content + "\x00" + image}467 if seen[key] {468 continue469 }470 seen[key] = true471 if counts[i] == nil {472 counts[i] = map[string]socialReaction{}473 }474 variant := content + "\x00" + image475 r := counts[i][variant]476 r.Content = content477 r.Image = image478 r.Count++479 r.Reacted = r.Reacted || row.PubKey == actor480 counts[i][variant] = r481 } else if row.Kind == 1 || row.Kind == 1111 {482 for i, post := range rows {483 if socialCommentFor(row, post) {484 items[i].ReplyCount++485 }486 }487 }488 }489 for i, group := range counts {490 for _, r := range group {491 items[i].Reactions = append(items[i].Reactions, r)492 }493 sort.Slice(items[i].Reactions, func(a, b int) bool {494 if items[i].Reactions[a].Content == items[i].Reactions[b].Content {495 return items[i].Reactions[a].Image < items[i].Reactions[b].Image496 }497 return items[i].Reactions[a].Content < items[i].Reactions[b].Content498 })499 }500 return items501}502503// socialReactionValue applies NIP-30 custom emoji metadata only when the504// reaction content and emoji tag form a valid, safe pair. Invalid metadata is505// retained as ordinary reaction text so it cannot turn into an image URL.506func socialReactionValue(row event.Event) (string, string) {507 content := row.Content508 if content == "" {509 return "+", ""510 }511 if len(content) < 3 || content[0] != ':' || content[len(content)-1] != ':' {512 return content, ""513 }514 shortcode := content[1 : len(content)-1]515 if !socialEmojiShortcode(shortcode) {516 return content, ""517 }518 for _, tag := range row.Tags {519 if len(tag) < 3 || tag[0] != "emoji" || tag[1] != shortcode {520 continue521 }522 if socialEmojiURL(tag[2]) {523 return content, tag[2]524 }525 }526 return content, ""527}528529func socialEmojiShortcode(value string) bool {530 if value == "" {531 return false532 }533 for _, char := range value {534 if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' {535 return false536 }537 }538 return true539}540541func socialEmojiURL(value string) bool {542 parsed, err := url.Parse(value)543 return err == nil && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.Hostname() != "" && parsed.User == nil544}545546type socialRef struct {547 root, parent, rootAuthor, parentAuthor string548 rootKind, parentKind int549 rootAddress, parentAddress string550}551552func tagValue(tag []string, index int) string {553 if len(tag) > index {554 return tag[index]555 }556 return ""557}558func atoiTag(value string) int { n, _ := strconv.Atoi(value); return n }559func addressAuthor(address string) string {560 parts := strings.SplitN(address, ":", 3)561 if len(parts) == 3 {562 return parts[1]563 }564 return ""565}566func socialReference(row event.Event) socialRef {567 ref := socialRef{}568 if row.Kind == 1 {569 var legacy [][]string570 marked := false571 for _, tag := range row.Tags {572 if len(tag) < 2 {573 continue574 }575 if tag[0] == "a" {576 ref.parentAddress = tag[1]577 ref.rootAddress = tag[1]578 continue579 }580 if tag[0] != "e" {581 continue582 }583 switch tagValue(tag, 3) {584 case "root":585 ref.root, ref.rootAuthor = tag[1], tagValue(tag, 4)586 marked = true587 case "reply":588 ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 4)589 marked = true590 case "":591 legacy = append(legacy, tag)592 }593 }594 if !marked && len(legacy) > 0 {595 ref.root = legacy[0][1]596 ref.parent = legacy[len(legacy)-1][1]597 }598 if ref.root == "" {599 ref.root, ref.rootAuthor = ref.parent, ref.parentAuthor600 }601 if ref.parent == "" {602 ref.parent, ref.parentAuthor = ref.root, ref.rootAuthor603 }604 ref.rootKind, ref.parentKind = 1, 1605 return ref606 }607 for _, tag := range row.Tags {608 if len(tag) < 2 {609 continue610 }611 switch tag[0] {612 case "E":613 ref.root, ref.rootAuthor = tag[1], tagValue(tag, 3)614 case "e":615 ref.parent, ref.parentAuthor = tag[1], tagValue(tag, 3)616 case "A":617 ref.rootAddress = tag[1]618 case "a":619 ref.parentAddress = tag[1]620 case "K":621 ref.rootKind = atoiTag(tag[1])622 case "k":623 ref.parentKind = atoiTag(tag[1])624 case "P":625 ref.rootAuthor = tag[1]626 case "p":627 ref.parentAuthor = tag[1]628 }629 }630 if ref.rootAddress != "" && ref.rootAuthor == "" {631 ref.rootAuthor = addressAuthor(ref.rootAddress)632 }633 if ref.parentAddress != "" && ref.parentAuthor == "" {634 ref.parentAuthor = addressAuthor(ref.parentAddress)635 }636 return ref637}638639func socialCommentFor(comment, post event.Event) bool {640 ref := socialReference(comment)641 if comment.Kind == 1 && post.Kind == 1 {642 return ref.parent == post.ID || ref.root == post.ID643 }644 if post.Kind == podcasts.KindEpisode {645 if comment.Kind != 1111 {646 return false647 }648 return ref.rootKind == podcasts.KindEpisode && ref.root == post.ID649 }650 if post.Kind == 30023 {651 address := socialAddress(post)652 if comment.Kind == 1111 {653 return ref.rootKind == post.Kind && ((address != "" && ref.rootAddress == address) || ref.root == post.ID)654 }655 // Older long-form clients used kind 1 with an article a tag. Read these656 // comments for compatibility, but new comments always use NIP-22.657 return comment.Kind == 1 && address != "" && ref.rootAddress == address658 }659 if post.Kind == 1111 {660 return comment.Kind == 1111 && ref.parent == post.ID && ref.parentKind == 1111661 }662 return false663}664func socialItemFrom(row event.Event) socialItem {665 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)}666 if item.Kind == podcasts.KindEpisode && item.Summary == "" {667 item.Summary = event.Tag(row, "description")668 }669 item.ReadPath = "/social/" + row.ID670 if item.Address != "" {671 item.ReadPath = "/social?address=" + url.QueryEscape(item.Address)672 item.ReadMinutes = maxInt(1, (len(strings.Fields(row.Content))+219)/220)673 }674 ref := socialReference(row)675 item.ParentID, item.ParentAuthor, item.ParentKind, item.ParentAddress = ref.parent, ref.parentAuthor, ref.parentKind, ref.parentAddress676 if ref.root == "" && ref.rootAddress == "" {677 ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress = row.ID, row.PubKey, row.Kind, item.Address678 }679 item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = ref.root, ref.rootAuthor, ref.rootKind, ref.rootAddress680 return item681}682func maxInt(a, b int) int {683 if a > b {684 return a685 }686 return b687}688func socialFilter(identifier string) (event.Filter, error) {689 if socialHex(identifier) {690 return event.Filter{IDs: []string{identifier}}, nil691 }692 parts := strings.SplitN(identifier, ":", 3)693 if len(parts) != 3 || parts[0] != "30023" || !socialHex(parts[1]) {694 return event.Filter{}, errors.New("invalid: social post address")695 }696 return event.Filter{Kinds: []int{30023}, Authors: []string{parts[1]}, Tags: map[string][]string{"d": {parts[2]}}}, nil697}698func (t *Tenant) socialPost(ctx context.Context, actor, identifier string) (event.Event, error) {699 filter, err := socialFilter(identifier)700 if err != nil {701 return event.Event{}, err702 }703 rows, err := t.socialEvents(ctx, actor, []event.Filter{filter})704 if err != nil {705 return event.Event{}, err706 }707 if len(rows) != 1 || (rows[0].Kind != 1 && rows[0].Kind != 30023 && rows[0].Kind != podcasts.KindEpisode && rows[0].Kind != 1111) || event.Tag(rows[0], "h") != "" {708 return event.Event{}, errors.New("not found: social post")709 }710 if rows[0].Kind == podcasts.KindEpisode {711 if _, ok := podcasts.ParseEpisode(rows[0]); !ok {712 return event.Event{}, errors.New("not found: podcast episode")713 }714 }715 if rows[0].Kind == 1111 && socialReference(rows[0]).rootKind != 30023 && socialReference(rows[0]).rootKind != podcasts.KindEpisode {716 return event.Event{}, errors.New("not found: social comment")717 }718 return rows[0], nil719}720func (t *Tenant) browseSocialThread(ctx context.Context, actor string, q socialBrowseRequest) (any, error) {721 identifier := q.ID722 if identifier == "" {723 identifier = q.Address724 }725 post, err := t.socialPost(ctx, actor, identifier)726 if err != nil {727 return nil, err728 }729 items, err := t.socialItems(ctx, actor, []event.Event{post})730 if err != nil {731 return nil, err732 }733 item := items[0]734 if post.Kind == 1111 || post.Kind == 1 && item.ParentID != "" {735 rootIdentifier := item.RootID736 if item.RootAddress != "" {737 rootIdentifier = item.RootAddress738 }739 root, rootErr := t.socialPost(ctx, actor, rootIdentifier)740 if post.Kind == 1111 && rootErr != nil {741 return nil, errors.New("not found: article for comment")742 }743 if rootErr == nil {744 item.RootID, item.RootAuthor, item.RootKind, item.RootAddress = root.ID, root.PubKey, root.Kind, socialAddress(root)745 }746 }747 cursor, err := parseCollaborationCursor(q.Cursor)748 if err != nil {749 return nil, err750 }751 activity, err := t.socialEvents(ctx, actor, socialActivityFilters([]event.Event{post}))752 if err != nil {753 return nil, err754 }755 replies := []event.Event{}756 for _, row := range activity {757 if !socialCommentFor(row, post) {758 continue759 }760 if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) {761 continue762 }763 replies = append(replies, row)764 }765 next := ""766 if len(replies) > q.Limit {767 next = socialCursor(replies[q.Limit-1])768 replies = replies[:q.Limit]769 }770 comments, err := t.socialItems(ctx, actor, replies)771 if err != nil {772 return nil, err773 }774 return map[string]any{"post": item, "comments": comments, "next_cursor": next}, nil775}