tuiter 2006
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}