tuiter 2006
at main 840 lines 23 kB view raw
1package main 2 3import ( 4 "context" 5 "fmt" 6 "log" 7 "net/http" 8 "regexp" 9 "strings" 10 11 "github.com/bluesky-social/indigo/api/atproto" 12 bsky "github.com/bluesky-social/indigo/api/bsky" 13 "github.com/bluesky-social/indigo/atproto/client" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/bluesky-social/indigo/lex/util" 16) 17 18// PostsList is a small, type-safe wrapper passed to templates to render lists of posts 19// and provide optional pagination cursor in a single place. 20type PostsList struct { 21 Items []*bsky.FeedDefs_FeedViewPost 22 Cursor string 23 // ParentPreviews holds pre-fetched ParentInfo keyed by parent URI. Handlers should populate 24 // this map by collecting all reply-ref URIs and calling fetchPostsBatch once. 25 ParentPreviews map[string]ParentInfo 26} 27 28func getPostText(record *util.LexiconTypeDecoder) string { 29 if record == nil || record.Val == nil { 30 log.Printf("DEBUG: getPostText - record or record.Val is nil") 31 return "[Post content unavailable]" 32 } 33 if post, ok := record.Val.(*bsky.FeedPost); ok && post != nil { 34 return post.Text 35 } 36 log.Printf("DEBUG: getPostText - unable to extract text from record type: %T", record.Val) 37 return "[Post content unavailable]" 38} 39 40func resolveHandleToDID(ctx context.Context, c *client.APIClient, identifier string) (string, error) { 41 if strings.HasPrefix(identifier, "did:") { 42 return identifier, nil 43 } 44 profile, err := bsky.ActorGetProfile(ctx, c, identifier) 45 if err != nil { 46 log.Printf("DEBUG: resolveHandleToDID - error resolving handle %s: %v", identifier, err) 47 return "", err 48 } 49 return profile.Did, nil 50} 51 52func executeTemplate(w http.ResponseWriter, templateName string, data interface{}) { 53 // Ensure templates that reference .SignedIn won't panic when handlers pass nil 54 if data == nil { 55 // minimal typed wrapper with SignedIn nil 56 data = struct { 57 SignedIn *bsky.ActorDefs_ProfileViewDetailed 58 }{} 59 } 60 if err := tpl.ExecuteTemplate(w, templateName, data); err != nil { 61 log.Printf("Template execution error for %s: %v", templateName, err) 62 http.Error(w, "Internal server error", http.StatusInternalServerError) 63 } 64} 65 66func getIntFromProfile(obj interface{}, keys []string) int { 67 if obj == nil { 68 return 0 69 } 70 switch p := obj.(type) { 71 case *bsky.ActorDefs_ProfileViewDetailed: 72 if p == nil { 73 return 0 74 } 75 for _, k := range keys { 76 switch k { 77 case "followersCount", "followers_count", "followers": 78 if p.FollowersCount != nil { 79 return int(*p.FollowersCount) 80 } 81 case "followsCount", "follows_count", "following", "follows": 82 if p.FollowsCount != nil { 83 return int(*p.FollowsCount) 84 } 85 case "postsCount", "posts_count", "posts": 86 if p.PostsCount != nil { 87 return int(*p.PostsCount) 88 } 89 } 90 } 91 default: 92 return 0 93 } 94 return 0 95} 96 97func getDisplayNameFromProfile(obj interface{}) string { 98 if obj == nil { 99 return "" 100 } 101 switch p := obj.(type) { 102 case *bsky.ActorDefs_ProfileViewDetailed: 103 if p == nil { 104 return "" 105 } 106 if p.DisplayName != nil && *p.DisplayName != "" { 107 return *p.DisplayName 108 } 109 if p.Handle != "" { 110 return p.Handle 111 } 112 return "" 113 case *bsky.ActorDefs_ProfileView: 114 if p == nil { 115 return "" 116 } 117 if p.DisplayName != nil && *p.DisplayName != "" { 118 return *p.DisplayName 119 } 120 if p.Handle != "" { 121 return p.Handle 122 } 123 return "" 124 case *bsky.ActorDefs_ProfileViewBasic: 125 if p == nil { 126 return "" 127 } 128 if p.DisplayName != nil && *p.DisplayName != "" { 129 return *p.DisplayName 130 } 131 if p.Handle != "" { 132 return p.Handle 133 } 134 return "" 135 default: 136 return "" 137 } 138} 139 140func getClientFromSession(ctx context.Context, r *http.Request) (*client.APIClient, string, error) { 141 session, _ := store.Get(r, sessionName) 142 didStr, ok := session.Values["did"].(string) 143 if !ok || didStr == "" { 144 return nil, "", fmt.Errorf("not logged in") 145 } 146 sessionID, ok := session.Values["session_id"].(string) 147 if !ok || sessionID == "" { 148 return nil, "", fmt.Errorf("not logged in") 149 } 150 did, err := syntax.ParseDID(didStr) 151 if err != nil { 152 return nil, "", err 153 } 154 sess, err := oauthApp.ResumeSession(ctx, did, sessionID) 155 if err != nil { 156 return nil, "", err 157 } 158 return sess.APIClient(), didStr, nil 159} 160 161func fetchFollows(ctx context.Context, c *client.APIClient, did string, limit int64) []*bsky.ActorDefs_ProfileView { 162 follows, err := bsky.GraphGetFollows(ctx, c, did, "", limit) 163 if err != nil { 164 log.Printf("DEBUG: fetchFollows - error fetching follows for %s: %v", did, err) 165 return nil 166 } 167 if follows == nil || follows.Follows == nil { 168 return nil 169 } 170 return follows.Follows 171} 172 173func fetchProfile(ctx context.Context, c *client.APIClient, idOrHandle string) (*bsky.ActorDefs_ProfileViewDetailed, error) { 174 if idOrHandle == "" { 175 return nil, fmt.Errorf("empty identifier") 176 } 177 if !strings.HasPrefix(idOrHandle, "did:") { 178 resolved, err := resolveHandleToDID(ctx, c, idOrHandle) 179 if err != nil { 180 return nil, err 181 } 182 idOrHandle = resolved 183 } 184 profile, err := bsky.ActorGetProfile(ctx, c, idOrHandle) 185 if err != nil { 186 return nil, err 187 } 188 return profile, nil 189} 190 191func getCursorFromTimeline(t *bsky.FeedGetTimeline_Output) string { 192 if t == nil { 193 return "" 194 } 195 if t.Cursor != nil { 196 return *t.Cursor 197 } 198 return "" 199} 200 201func getCursorFromAuthorFeed(f *bsky.FeedGetAuthorFeed_Output) string { 202 if f == nil { 203 return "" 204 } 205 if f.Cursor != nil { 206 return *f.Cursor 207 } 208 return "" 209} 210 211func getCursorFromAny(v interface{}) string { 212 switch t := v.(type) { 213 case *bsky.FeedGetTimeline_Output: 214 return getCursorFromTimeline(t) 215 case *bsky.FeedGetAuthorFeed_Output: 216 return getCursorFromAuthorFeed(t) 217 default: 218 return "" 219 } 220} 221 222func getProfileURL(actor interface{}) string { 223 switch a := actor.(type) { 224 case *bsky.ActorDefs_ProfileView: 225 if a != nil && a.Handle != "" { 226 return "/profile/" + a.Handle 227 } 228 case *bsky.ActorDefs_ProfileViewBasic: 229 if a != nil && a.Handle != "" { 230 return "/profile/" + a.Handle 231 } 232 case *bsky.ActorDefs_ProfileViewDetailed: 233 if a != nil && a.Handle != "" { 234 return "/profile/" + a.Handle 235 } 236 } 237 return "#" 238} 239 240func getPostURL(post *bsky.FeedDefs_PostView) string { 241 if post != nil && post.Author != nil && post.Author.Handle != "" && post.Uri != "" { 242 uriParts := strings.Split(post.Uri, "/") 243 if len(uriParts) >= 4 { 244 postID := uriParts[len(uriParts)-1] 245 return "/post/" + post.Author.Handle + "/" + postID 246 } 247 } 248 return "#" 249} 250 251func getFollowingCount(actor interface{}) int { 252 return getIntFromProfile(actor, []string{"followsCount", "follows_count", "following", "follows"}) 253} 254func getFollowersCount(actor interface{}) int { 255 return getIntFromProfile(actor, []string{"followersCount", "followers_count", "followers"}) 256} 257func getPostsCount(actor interface{}) int { 258 return getIntFromProfile(actor, []string{"postsCount", "posts_count", "posts"}) 259} 260 261// Post type helpers 262 263type PostType int 264 265const ( 266 PostTypeAuthored PostType = iota 267 PostTypeRetweet 268 PostTypeQuote 269) 270 271func GetPostType(fvp *bsky.FeedDefs_FeedViewPost) PostType { 272 if fvp == nil || fvp.Post == nil { 273 return PostTypeAuthored 274 } 275 if fvp.Reason != nil && fvp.Reason.FeedDefs_ReasonRepost != nil { 276 return PostTypeRetweet 277 } 278 if fvp.Post.Embed != nil && fvp.Post.Embed.EmbedRecord_View != nil { 279 return PostTypeQuote 280 } 281 return PostTypeAuthored 282} 283 284func GetPostPrefix(fvp *bsky.FeedDefs_FeedViewPost) string { 285 switch GetPostType(fvp) { 286 case PostTypeRetweet: 287 return "RT" 288 case PostTypeQuote: 289 return "QT" 290 default: 291 return "" 292 } 293} 294 295// Convenience boolean helpers for templates 296func IsPostRetweet(item interface{}) bool { 297 switch it := item.(type) { 298 case *bsky.FeedDefs_FeedViewPost: 299 return GetPostType(it) == PostTypeRetweet 300 default: 301 return false 302 } 303} 304 305func IsPostQuote(item interface{}) bool { 306 switch it := item.(type) { 307 case *bsky.FeedDefs_FeedViewPost: 308 return GetPostType(it) == PostTypeQuote 309 default: 310 return false 311 } 312} 313 314// Embed helpers 315 316type EmbedRecordViewRecord struct { 317 Author interface{} 318 Value *util.LexiconTypeDecoder 319} 320 321func GetEmbedRecord(post *bsky.FeedDefs_PostView) *EmbedRecordViewRecord { 322 if post == nil || post.Embed == nil || post.Embed.EmbedRecord_View == nil || post.Embed.EmbedRecord_View.Record == nil { 323 return nil 324 } 325 recordWrapper := post.Embed.EmbedRecord_View.Record 326 if recordWrapper.EmbedRecord_ViewRecord == nil { 327 return nil 328 } 329 rr := recordWrapper.EmbedRecord_ViewRecord 330 return &EmbedRecordViewRecord{Author: rr.Author, Value: rr.Value} 331} 332 333type EmbedTemplateContext struct { 334 Parent *bsky.FeedDefs_PostView 335 Embed *EmbedRecordViewRecord 336} 337 338func embedContext(parent *bsky.FeedDefs_PostView, embed *EmbedRecordViewRecord) *EmbedTemplateContext { 339 return &EmbedTemplateContext{Parent: parent, Embed: embed} 340} 341 342// Small avatar/banner helpers to keep templates simple and avoid repeating conditionals. 343func AvatarURL(actor interface{}) string { 344 switch a := actor.(type) { 345 case *bsky.ActorDefs_ProfileView: 346 if a != nil && a.Avatar != nil { 347 return *a.Avatar 348 } 349 case *bsky.ActorDefs_ProfileViewBasic: 350 if a != nil && a.Avatar != nil { 351 return *a.Avatar 352 } 353 case *bsky.ActorDefs_ProfileViewDetailed: 354 if a != nil && a.Avatar != nil { 355 return *a.Avatar 356 } 357 case *bsky.FeedDefs_PostView: 358 // allow passing a PostView directly 359 if a != nil && a.Author != nil && a.Author.Avatar != nil { 360 return *a.Author.Avatar 361 } 362 } 363 return "" 364} 365 366func HasAvatar(actor interface{}) bool { 367 return AvatarURL(actor) != "" 368} 369 370func BannerURL(actor interface{}) string { 371 switch a := actor.(type) { 372 case *bsky.ActorDefs_ProfileViewDetailed: 373 if a != nil && a.Banner != nil { 374 return *a.Banner 375 } 376 } 377 return "" 378} 379 380// Post box helpers 381func PostBoxInitial(handle string) string { 382 if handle == "" { 383 return "" 384 } 385 return "@" + handle + " " 386} 387 388func PostBoxPlaceholder(handle string) string { 389 if handle == "" { 390 return "What are you doing?" 391 } 392 return "Mention " + handle 393} 394 395// PostVM is a small, template-friendly view model for posts. 396type PostVM struct { 397 AuthorDisplayName string 398 AuthorHandle string 399 AuthorAvatar string 400 Text string 401 PostURL string 402 IndexedAt string 403 ReplyCount int 404 IsQuote bool 405 IsRetweet bool 406 ParentPost *bsky.FeedDefs_PostView 407 EmbedRecord *EmbedRecordViewRecord 408 Raw *bsky.FeedDefs_FeedViewPost // keep raw for advanced helpers if needed 409} 410 411// BuildPostVM converts a typed feed view post into a PostVM for templates. 412func BuildPostVM(ctx context.Context, item *bsky.FeedDefs_FeedViewPost) *PostVM { 413 if item == nil || item.Post == nil { 414 return nil 415 } 416 post := item.Post 417 vm := &PostVM{Raw: item} 418 // author 419 if post.Author != nil { 420 if post.Author.Handle != "" { 421 vm.AuthorHandle = post.Author.Handle 422 } 423 if post.Author.DisplayName != nil && *post.Author.DisplayName != "" { 424 vm.AuthorDisplayName = *post.Author.DisplayName 425 } else if post.Author.Handle != "" { 426 vm.AuthorDisplayName = post.Author.Handle 427 } 428 if post.Author.Avatar != nil && *post.Author.Avatar != "" { 429 vm.AuthorAvatar = *post.Author.Avatar 430 } 431 } 432 // text and metadata 433 vm.Text = getPostText(post.Record) 434 vm.PostURL = getPostURL(post) 435 vm.IndexedAt = post.IndexedAt 436 if post.ReplyCount != nil { 437 vm.ReplyCount = int(*post.ReplyCount) 438 } 439 // embed / type 440 vm.IsRetweet = item.Reason != nil && item.Reason.FeedDefs_ReasonRepost != nil 441 vm.IsQuote = post.Embed != nil && post.Embed.EmbedRecord_View != nil 442 // parent and embed record 443 if post != nil { 444 vm.ParentPost = post 445 } 446 if vm.IsQuote { 447 vm.EmbedRecord = GetEmbedRecord(post) 448 } 449 return vm 450} 451 452// Helper wrapper exposed to templates: convert interface{} (feed item) to *PostVM 453func buildPostVMForTemplate(item interface{}) *PostVM { 454 switch it := item.(type) { 455 case *bsky.FeedDefs_FeedViewPost: 456 return BuildPostVM(context.Background(), it) 457 default: 458 log.Printf("DEBUG: buildPostVMForTemplate - unexpected type %T", item) 459 return nil 460 } 461} 462 463// Add helper to expose LikeCount safely to templates. 464func getLikeCount(post *bsky.FeedDefs_PostView) int { 465 if post == nil || post.LikeCount == nil { 466 return 0 467 } 468 return int(*post.LikeCount) 469} 470 471// MakeElementID converts an at:// URI into a safe DOM id (alphanumeric and dashes) 472func MakeElementID(uri string) string { 473 if uri == "" { 474 return "" 475 } 476 // remove scheme prefix if present 477 uri = strings.TrimPrefix(uri, "at://") 478 // replace non-alphanumeric characters with dash 479 re := regexp.MustCompile(`[^a-zA-Z0-9]+`) 480 id := re.ReplaceAllString(uri, "-") 481 // ensure doesn't start with digit-only? keep as-is 482 return "post-" + strings.Trim(id, "-") 483} 484 485// ThreadNodeWrapper bundles a ThreadViewPost with the ViewedURI so templates can access both typed values safely. 486type ThreadNodeWrapper struct { 487 Post *bsky.FeedDefs_ThreadViewPost 488 ViewedURI string 489} 490 491// wrapThread is a template helper that wraps a ThreadViewPost with the current viewed URI. 492func wrapThread(n *bsky.FeedDefs_ThreadViewPost, viewedURI string) ThreadNodeWrapper { 493 return ThreadNodeWrapper{Post: n, ViewedURI: viewedURI} 494} 495 496// HasItems is a tiny helper to ask if a PostsList has items; keeps templates readable. 497func HasItems(pl *PostsList) bool { 498 return pl != nil && len(pl.Items) > 0 499} 500 501// Media view models for templates 502type ImageVM struct { 503 Thumb string 504 Full string 505 Alt string 506} 507 508type VideoVM struct { 509 Thumb string 510 Cid string 511 Playlist string 512 OwnerDid string 513} 514 515type ExternalVM struct { 516 Title string 517 Description string 518 Thumb string 519 Uri string 520} 521 522type MediaVM struct { 523 Images []ImageVM 524 Video *VideoVM 525 External *ExternalVM 526} 527 528// GetPostMedia inspects a post's embed fields and returns a small, typed 529// MediaVM suitable for templates. It supports images, videos (thumbnail only) 530// and external link previews. Returns nil if no media present. 531func GetPostMedia(post *bsky.FeedDefs_PostView) *MediaVM { 532 if post == nil || post.Embed == nil { 533 return nil 534 } 535 m := &MediaVM{} 536 537 // top-level images 538 if post.Embed.EmbedImages_View != nil && post.Embed.EmbedImages_View.Images != nil { 539 for _, im := range post.Embed.EmbedImages_View.Images { 540 if im == nil { 541 continue 542 } 543 m.Images = append(m.Images, ImageVM{Thumb: im.Thumb, Full: im.Fullsize, Alt: im.Alt}) 544 } 545 } 546 547 // recordWithMedia (nested media inside an embedded record) 548 if post.Embed.EmbedRecordWithMedia_View != nil && post.Embed.EmbedRecordWithMedia_View.Media != nil { 549 mm := post.Embed.EmbedRecordWithMedia_View.Media 550 if mm.EmbedImages_View != nil && mm.EmbedImages_View.Images != nil { 551 for _, im := range mm.EmbedImages_View.Images { 552 if im == nil { 553 continue 554 } 555 m.Images = append(m.Images, ImageVM{Thumb: im.Thumb, Full: im.Fullsize, Alt: im.Alt}) 556 } 557 } 558 if mm.EmbedVideo_View != nil { 559 v := mm.EmbedVideo_View 560 var thumb string 561 if v.Thumbnail != nil { 562 thumb = *v.Thumbnail 563 } 564 ownerDid := "" 565 if post.Author != nil { 566 ownerDid = post.Author.Did 567 } 568 m.Video = &VideoVM{Thumb: thumb, Cid: v.Cid, Playlist: v.Playlist, OwnerDid: ownerDid} 569 } 570 if mm.EmbedExternal_View != nil && mm.EmbedExternal_View.External != nil { 571 ex := mm.EmbedExternal_View.External 572 extVM := &ExternalVM{Title: ex.Title, Description: ex.Description, Uri: ex.Uri} 573 if ex.Thumb != nil { 574 extVM.Thumb = *ex.Thumb 575 } 576 m.External = extVM 577 } 578 } 579 580 // top-level external 581 if post.Embed.EmbedExternal_View != nil && post.Embed.EmbedExternal_View.External != nil { 582 ex := post.Embed.EmbedExternal_View.External 583 extVM := &ExternalVM{Title: ex.Title, Description: ex.Description, Uri: ex.Uri} 584 if ex.Thumb != nil { 585 extVM.Thumb = *ex.Thumb 586 } 587 m.External = extVM 588 } 589 590 // top-level video view 591 if post.Embed.EmbedVideo_View != nil { 592 v := post.Embed.EmbedVideo_View 593 var thumb string 594 if v.Thumbnail != nil { 595 thumb = *v.Thumbnail 596 } 597 ownerDid := "" 598 if post.Author != nil { 599 ownerDid = post.Author.Did 600 } 601 m.Video = &VideoVM{Thumb: thumb, Cid: v.Cid, Playlist: v.Playlist, OwnerDid: ownerDid} 602 } 603 604 if len(m.Images) == 0 && m.Video == nil && m.External == nil { 605 return nil 606 } 607 return m 608} 609 610// GetMediaForTemplate accepts either a *bsky.FeedDefs_PostView or a *MediaVM and returns a *MediaVM 611// This lets templates call a single helper when they may have either the full PostView or a precomputed MediaVM. 612func GetMediaForTemplate(v interface{}) *MediaVM { 613 if v == nil { 614 return nil 615 } 616 switch t := v.(type) { 617 case *bsky.FeedDefs_PostView: 618 return GetPostMedia(t) 619 case *MediaVM: 620 return t 621 default: 622 return nil 623 } 624} 625 626// IsPostReply reports whether the given feed item is a reply (has a Reply ref). 627func IsPostReply(item interface{}) bool { 628 switch it := item.(type) { 629 case *bsky.FeedDefs_FeedViewPost: 630 if it == nil || it.Post == nil { 631 return false 632 } 633 // consider it a reply only if a parent/root URI is present 634 parentURI := extractReplyParentURI(it.Post) 635 return parentURI != "" 636 default: 637 return false 638 } 639} 640 641// ReplyParentURI returns the parent URI for a post's reply reference, or empty string. 642func ReplyParentURI(pv *bsky.FeedDefs_PostView) string { 643 return extractReplyParentURI(pv) 644} 645 646// ShortURI returns a compact representation of an at:// post URI (did/postid) or the original string. 647func ShortURI(uri string) string { 648 if uri == "" { 649 return "" 650 } 651 // expected form: at://did/app.bsky.feed.post/postid 652 if strings.HasPrefix(uri, "at://") { 653 parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") 654 if len(parts) >= 3 { 655 // parts[0]=did, parts[1]=app.bsky.feed.post, parts[2]=postid 656 return parts[0] + "/" + parts[len(parts)-1] 657 } 658 } 659 return uri 660} 661 662// ParentInfo captures lightweight parent details available from a ReplyRef without fetching the parent post. 663type ParentInfo struct { 664 AuthorName string 665 AuthorHandle string 666 Text string 667 Uri string 668 Avatar string 669 PostURL string 670 IndexedAt string 671 Media *MediaVM 672 // whether the signed-in viewer has liked this post (from PostView.Viewer.Like) 673 IsFav bool 674 // like count for the parent post (populated by handlers from PostView.LikeCount) 675 LikeCount int 676 ReplyCount int 677 RepostCount int 678} 679 680// GetParentInfo extracts whatever metadata is present in the ReplyRef.Parent or ReplyRef.Root 681// using concrete, type-safe assertions (no reflection). It prefers Parent over Root and 682// only extracts the Uri when available from known concrete types. 683func GetParentInfo(pv *bsky.FeedDefs_PostView) ParentInfo { 684 pi := ParentInfo{} 685 if pv == nil || pv.Record == nil || pv.Record.Val == nil { 686 return pi 687 } 688 post, ok := pv.Record.Val.(*bsky.FeedPost) 689 if !ok || post == nil || post.Reply == nil { 690 return pi 691 } 692 // prefer Parent over Root 693 var ref interface{} 694 if post.Reply.Parent != nil { 695 ref = post.Reply.Parent 696 } else if post.Reply.Root != nil { 697 ref = post.Reply.Root 698 } 699 if ref == nil { 700 return pi 701 } 702 // set Uri if available via existing helper 703 pi.Uri = extractReplyParentURI(pv) 704 705 // Try known concrete types (atproto.RepoStrongRef) to extract Uri 706 if sr, ok := ref.(*atproto.RepoStrongRef); ok { 707 if sr.Uri != "" { 708 pi.Uri = sr.Uri 709 } 710 return pi 711 } 712 713 // If other concrete types are introduced by the API, avoid reflection and return what we have. 714 return pi 715} 716 717// GetReplyChainInfos extracts available reply-ref metadata from a PostView without performing network fetches. 718// It returns a slice of ParentInfo ordered from root (top-most ancestor) to immediate parent. 719// This implementation is type-safe and only uses concrete types; it will populate Uri when available. 720// NOTE: Reply refs carry only lightweight references (Uri/Cid). To display author handles, display names 721// and text previews for ancestors, handlers should collect all referenced URIs and call fetchPostsBatch 722// once to obtain full PostView objects, then populate a ParentInfo map passed into templates. Helpers 723// must not perform network I/O (per project rules), so this function intentionally avoids fetching. 724func GetReplyChainInfos(pv *bsky.FeedDefs_PostView) []ParentInfo { 725 var out []ParentInfo 726 if pv == nil || pv.Record == nil || pv.Record.Val == nil { 727 return out 728 } 729 post, ok := pv.Record.Val.(*bsky.FeedPost) 730 if !ok || post == nil || post.Reply == nil { 731 return out 732 } 733 734 // helper to extract info from a reply-ref struct (root or parent) 735 extract := func(ref interface{}) ParentInfo { 736 pi := ParentInfo{} 737 if ref == nil { 738 return pi 739 } 740 // If the concrete type is a RepoStrongRef, extract Uri 741 if sr, ok := ref.(*atproto.RepoStrongRef); ok { 742 if sr.Uri != "" { 743 pi.Uri = sr.Uri 744 } 745 return pi 746 } 747 // Unknown concrete type: avoid reflection and return empty 748 return pi 749 } 750 751 // prefer root then parent to produce top-down order 752 if post.Reply.Root != nil { 753 rootInfo := extract(post.Reply.Root) 754 out = append(out, rootInfo) 755 } 756 if post.Reply.Parent != nil { 757 parentInfo := extract(post.Reply.Parent) 758 // avoid duplicating the same Uri twice 759 if !(len(out) > 0 && out[len(out)-1].Uri != "" && parentInfo.Uri != "" && out[len(out)-1].Uri == parentInfo.Uri) { 760 out = append(out, parentInfo) 761 } 762 } 763 return out 764} 765 766// GetEmbeddedParentInfo inspects a post's embed record (if it's an embedded record view) 767// and returns a ParentInfo constructed from the embedded record's author and value fields. 768// This is useful for rendering a quoted record as an ancestor in the chat-like UI when 769// a full ReplyRef chain isn't available. 770func GetEmbeddedParentInfo(pv *bsky.FeedDefs_PostView) ParentInfo { 771 pi := ParentInfo{} 772 if pv == nil || pv.Embed == nil { 773 return pi 774 } 775 // Prefer embedded record view 776 if pv.Embed.EmbedRecord_View != nil && pv.Embed.EmbedRecord_View.Record != nil { 777 rw := pv.Embed.EmbedRecord_View.Record 778 // the wrapped record may be an EmbedRecord_ViewRecord 779 if rw.EmbedRecord_ViewRecord != nil { 780 r := rw.EmbedRecord_ViewRecord 781 // author: use existing helper to get a friendly display name 782 if r.Author != nil { 783 pi.AuthorName = getDisplayNameFromProfile(r.Author) 784 // attempt to extract a handle from known concrete author types by converting to interface{} 785 switch a := interface{}(r.Author).(type) { 786 case *bsky.ActorDefs_ProfileView: 787 pi.AuthorHandle = a.Handle 788 if a.Avatar != nil { 789 pi.Avatar = *a.Avatar 790 } 791 case *bsky.ActorDefs_ProfileViewBasic: 792 pi.AuthorHandle = a.Handle 793 if a.Avatar != nil { 794 pi.Avatar = *a.Avatar 795 } 796 case *bsky.ActorDefs_ProfileViewDetailed: 797 pi.AuthorHandle = a.Handle 798 if a.Avatar != nil { 799 pi.Avatar = *a.Avatar 800 } 801 default: 802 // unknown author shape - leave handle/avatar empty 803 } 804 } 805 // text/value: use getPostText which accepts *util.LexiconTypeDecoder 806 if r.Value != nil { 807 pi.Text = getPostText(r.Value) 808 } 809 } 810 } 811 return pi 812} 813 814// HasEmbedRecord reports whether the given FeedPost has an embedded record view 815func HasEmbedRecord(item *bsky.FeedDefs_FeedViewPost) bool { 816 if item == nil || item.Post == nil || item.Post.Embed == nil { 817 return false 818 } 819 if item.Post.Embed.EmbedRecord_View != nil { 820 return true 821 } 822 if item.Post.Embed.EmbedRecordWithMedia_View != nil { 823 return true 824 } 825 return false 826} 827 828func IsReply(item *bsky.FeedDefs_FeedViewPost) bool { 829 if item == nil || item.Post == nil { 830 return false 831 } 832 return extractReplyParentURI(item.Post) != "" 833} 834 835func getIsFav(post *bsky.FeedDefs_PostView) bool { 836 if post == nil || post.Viewer == nil || post.Viewer.Like == nil { 837 return false 838 } 839 return len(*post.Viewer.Like) > 0 840}