Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/bsky: switch to manual post fetching

indigo needs a version bump to support galleries in responses

Signed-off-by: oppiliappan <me@oppi.li>

authored by

oppiliappan and committed by
Tangled
(Jul 7, 2026, 1:53 PM +0300) f2714cce 7a0a64b1

+201 -71
+170 -14
appview/bsky/client.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "log" 7 + "net/http" 8 + "time" 6 9 7 - bsky "github.com/bluesky-social/indigo/api/bsky" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 8 11 "github.com/bluesky-social/indigo/xrpc" 9 12 "tangled.org/core/appview/models" 10 13 "tangled.org/core/consts" 11 14 ) 12 15 16 + type rawFeedOutput struct { 17 + Cursor *string `json:"cursor,omitempty"` 18 + Feed []rawFeedItem `json:"feed"` 19 + } 20 + 21 + type rawFeedItem struct { 22 + Post rawPostView `json:"post"` 23 + } 24 + 25 + type rawPostView struct { 26 + URI string `json:"uri"` 27 + Author struct { 28 + DID string `json:"did"` 29 + } `json:"author"` 30 + Record rawRecord `json:"record"` 31 + Embed json.RawMessage `json:"embed,omitempty"` 32 + LikeCount *int64 `json:"likeCount,omitempty"` 33 + ReplyCount *int64 `json:"replyCount,omitempty"` 34 + RepostCount *int64 `json:"repostCount,omitempty"` 35 + QuoteCount *int64 `json:"quoteCount,omitempty"` 36 + } 37 + 38 + type rawRecord struct { 39 + Text string `json:"text"` 40 + CreatedAt string `json:"createdAt"` 41 + Langs []string `json:"langs,omitempty"` 42 + Tags []string `json:"tags,omitempty"` 43 + Facets json.RawMessage `json:"facets,omitempty"` 44 + } 45 + 46 + type rawEmbedType struct { 47 + Type string `json:"$type"` 48 + } 49 + 13 50 func FetchPosts(ctx context.Context, c *xrpc.Client, limit int, cursor string) ([]models.BskyPost, string, error) { 14 - resp, err := bsky.FeedGetAuthorFeed(ctx, c, consts.TangledDid, cursor, "posts_no_replies", false, int64(limit)) 15 - if err != nil { 51 + var out rawFeedOutput 52 + 53 + params := map[string]any{ 54 + "actor": consts.TangledDid, 55 + "filter": "posts_no_replies", 56 + "limit": int64(limit), 57 + } 58 + if cursor != "" { 59 + params["cursor"] = cursor 60 + } 61 + if err := c.Do(ctx, http.MethodGet, "", "app.bsky.feed.getAuthorFeed", params, nil, &out); err != nil { 16 62 return nil, "", err 17 63 } 18 64 19 65 var posts []models.BskyPost 20 - for _, feedViewPost := range resp.Feed { 21 - // skip quote posts 22 - if feedViewPost.Post.Embed != nil && feedViewPost.Post.Embed.EmbedRecord_View != nil { 66 + for _, feedItem := range out.Feed { 67 + raw := feedItem.Post 68 + 69 + if len(raw.Embed) > 0 { 70 + var t rawEmbedType 71 + json.Unmarshal(raw.Embed, &t) 72 + if t.Type == "app.bsky.embed.record#view" { 73 + continue 74 + } 75 + } 76 + 77 + atUri, err := syntax.ParseATURI(raw.URI) 78 + if err != nil { 79 + log.Println("bsky: parse AT URI:", err) 23 80 continue 24 81 } 25 82 26 - post, err := models.NewBskyPostFromView(feedViewPost.Post) 83 + createdAt, err := time.Parse(time.RFC3339, raw.Record.CreatedAt) 27 84 if err != nil { 28 - log.Println(err) 85 + log.Println("bsky: parse createdAt:", err) 29 86 continue 30 87 } 31 88 32 - posts = append(posts, *post) 89 + post := models.BskyPost{ 90 + AuthorDid: raw.Author.DID, 91 + Rkey: atUri.RecordKey().String(), 92 + Text: raw.Record.Text, 93 + CreatedAt: createdAt, 94 + Langs: raw.Record.Langs, 95 + Tags: raw.Record.Tags, 96 + Facets: raw.Record.Facets, 97 + } 98 + 99 + if raw.LikeCount != nil { 100 + post.LikeCount = *raw.LikeCount 101 + } 102 + if raw.ReplyCount != nil { 103 + post.ReplyCount = *raw.ReplyCount 104 + } 105 + if raw.RepostCount != nil { 106 + post.RepostCount = *raw.RepostCount 107 + } 108 + if raw.QuoteCount != nil { 109 + post.QuoteCount = *raw.QuoteCount 110 + } 111 + 112 + if len(raw.Embed) > 0 { 113 + embed, err := parseEmbed(raw.Embed) 114 + if err != nil { 115 + log.Println("bsky: parse embed:", err) 116 + } 117 + post.Embed = embed 118 + } 119 + 120 + posts = append(posts, post) 33 121 } 34 122 35 - return posts, stringPtr(resp.Cursor), nil 123 + nextCursor := "" 124 + if out.Cursor != nil { 125 + nextCursor = *out.Cursor 126 + } 127 + 128 + return posts, nextCursor, nil 36 129 } 37 130 38 - func stringPtr(s *string) string { 39 - if s == nil { 40 - return "" 131 + func parseEmbed(raw json.RawMessage) (*models.PostEmbed, error) { 132 + var t rawEmbedType 133 + if err := json.Unmarshal(raw, &t); err != nil { 134 + return nil, err 41 135 } 42 - return *s 136 + 137 + switch t.Type { 138 + case "app.bsky.embed.images#view": 139 + var v struct { 140 + Images []models.PostImage `json:"images"` 141 + } 142 + if err := json.Unmarshal(raw, &v); err != nil { 143 + return nil, err 144 + } 145 + return &models.PostEmbed{Images: v.Images}, nil 146 + 147 + case "app.bsky.embed.gallery#view": 148 + // gallery items use "thumbnail" instead of "thumb" 149 + var v struct { 150 + Items []struct { 151 + Fullsize string `json:"fullsize"` 152 + Thumbnail string `json:"thumbnail"` 153 + Alt string `json:"alt"` 154 + AspectRatio *models.AspectRatio `json:"aspectRatio,omitempty"` 155 + } `json:"items"` 156 + } 157 + if err := json.Unmarshal(raw, &v); err != nil { 158 + return nil, err 159 + } 160 + embed := &models.PostEmbed{} 161 + for _, item := range v.Items { 162 + embed.Images = append(embed.Images, models.PostImage{ 163 + Fullsize: item.Fullsize, 164 + Thumb: item.Thumbnail, 165 + Alt: item.Alt, 166 + AspectRatio: item.AspectRatio, 167 + }) 168 + } 169 + return embed, nil 170 + 171 + case "app.bsky.embed.external#view": 172 + var v struct { 173 + External models.PostExternal `json:"external"` 174 + } 175 + if err := json.Unmarshal(raw, &v); err != nil { 176 + return nil, err 177 + } 178 + return &models.PostEmbed{External: &v.External}, nil 179 + 180 + case "app.bsky.embed.video#view": 181 + var v models.PostVideo 182 + if err := json.Unmarshal(raw, &v); err != nil { 183 + return nil, err 184 + } 185 + return &models.PostEmbed{Video: &v}, nil 186 + 187 + case "app.bsky.embed.recordWithMedia#view": 188 + var v struct { 189 + Media json.RawMessage `json:"media"` 190 + } 191 + if err := json.Unmarshal(raw, &v); err != nil { 192 + return nil, err 193 + } 194 + return parseEmbed(v.Media) 195 + 196 + default: 197 + return nil, nil 198 + } 43 199 }
+3 -2
appview/db/bsky.go
··· 29 29 langsJSON, _ = json.Marshal(post.Langs) 30 30 } 31 31 if len(post.Facets) > 0 { 32 - facetsJSON, _ = json.Marshal(post.Facets) 32 + facetsJSON = post.Facets // already raw JSON bytes 33 33 } 34 34 if post.Embed != nil { 35 35 embedJSON, _ = json.Marshal(post.Embed) ··· 109 109 json.Unmarshal([]byte(facets.V), &post.Facets) 110 110 } 111 111 if embed.Valid && embed.V != "" { 112 - json.Unmarshal([]byte(embed.V), &post.Embed) 112 + post.Embed = new(models.PostEmbed) 113 + json.Unmarshal([]byte(embed.V), post.Embed) 113 114 } 114 115 115 116 posts = append(posts, post)
+28 -55
appview/models/bsky.go
··· 1 1 package models 2 2 3 3 import ( 4 + "encoding/json" 4 5 "time" 5 - 6 - apibsky "github.com/bluesky-social/indigo/api/bsky" 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 6 ) 9 7 10 8 type BskyPost struct { ··· 14 12 CreatedAt time.Time 15 13 Langs []string 16 14 Tags []string 17 - Embed *apibsky.FeedDefs_PostView_Embed 18 - Facets []*apibsky.RichtextFacet 19 - Labels *apibsky.FeedPost_Labels 20 - Reply *apibsky.FeedPost_ReplyRef 15 + Embed *PostEmbed 16 + Facets json.RawMessage 21 17 LikeCount int64 22 18 ReplyCount int64 23 19 RepostCount int64 24 20 QuoteCount int64 25 21 } 26 22 27 - func NewBskyPostFromView(postView *apibsky.FeedDefs_PostView) (*BskyPost, error) { 28 - atUri, err := syntax.ParseATURI(postView.Uri) 29 - if err != nil { 30 - return nil, err 31 - } 23 + type PostEmbed struct { 24 + Images []PostImage `json:"images,omitempty"` 25 + External *PostExternal `json:"external,omitempty"` 26 + Video *PostVideo `json:"video,omitempty"` 27 + } 32 28 33 - // decode the record to get FeedPost 34 - feedPost, ok := postView.Record.Val.(*apibsky.FeedPost) 35 - if !ok { 36 - return nil, err 37 - } 29 + type AspectRatio struct { 30 + Width int64 `json:"width"` 31 + Height int64 `json:"height"` 32 + } 38 33 39 - createdAt, err := time.Parse(time.RFC3339, feedPost.CreatedAt) 40 - if err != nil { 41 - return nil, err 42 - } 34 + type PostImage struct { 35 + Fullsize string `json:"fullsize"` 36 + Thumb string `json:"thumb"` 37 + Alt string `json:"alt"` 38 + AspectRatio *AspectRatio `json:"aspectRatio,omitempty"` 39 + } 43 40 44 - var likeCount, replyCount, repostCount, quoteCount int64 45 - if postView.LikeCount != nil { 46 - likeCount = *postView.LikeCount 47 - } 48 - if postView.ReplyCount != nil { 49 - replyCount = *postView.ReplyCount 50 - } 51 - if postView.RepostCount != nil { 52 - repostCount = *postView.RepostCount 53 - } 54 - if postView.QuoteCount != nil { 55 - quoteCount = *postView.QuoteCount 56 - } 41 + type PostExternal struct { 42 + Uri string `json:"uri"` 43 + Title string `json:"title"` 44 + Description string `json:"description"` 45 + Thumb string `json:"thumb"` 46 + } 57 47 58 - post := &BskyPost{ 59 - Rkey: atUri.RecordKey().String(), 60 - Text: feedPost.Text, 61 - CreatedAt: createdAt, 62 - Langs: feedPost.Langs, 63 - Tags: feedPost.Tags, 64 - Embed: postView.Embed, 65 - Facets: feedPost.Facets, 66 - Labels: feedPost.Labels, 67 - Reply: feedPost.Reply, 68 - LikeCount: likeCount, 69 - ReplyCount: replyCount, 70 - RepostCount: repostCount, 71 - QuoteCount: quoteCount, 72 - } 73 - 74 - if author := postView.Author; author != nil { 75 - post.AuthorDid = author.Did 76 - } 77 - 78 - return post, nil 48 + type PostVideo struct { 49 + Playlist string `json:"playlist"` 50 + Thumbnail string `json:"thumbnail"` 51 + Alt string `json:"alt"` 79 52 }