A locally focused bluesky appview

improve getPostThreadV2 a good deal

Changed files
+168 -153
hydration
xrpc
+12 -9
hydration/post.go
··· 17 17 18 18 // PostInfo contains hydrated post information 19 19 type PostInfo struct { 20 + ID uint 20 21 URI string 21 22 Cid string 22 23 Post *bsky.FeedPost ··· 39 40 ctx, span := tracer.Start(ctx, "hydratePost") 40 41 defer span.End() 41 42 43 + p, err := h.backend.GetPostByUri(ctx, uri, "*") 44 + if err != nil { 45 + return nil, err 46 + } 47 + 48 + return h.HydratePostDB(ctx, uri, p, viewerDID) 49 + } 50 + 51 + func (h *Hydrator) HydratePostDB(ctx context.Context, uri string, dbPost *models.Post, viewerDID string) (*PostInfo, error) { 42 52 autoFetch, _ := ctx.Value("auto-fetch").(bool) 43 53 44 54 authorDid := extractDIDFromURI(uri) ··· 47 57 return nil, err 48 58 } 49 59 50 - // Query post from database 51 - var dbPost models.Post 52 - if err := h.db.Raw(`SELECT * FROM posts WHERE author = ? AND rkey = ? `, r.ID, extractRkeyFromURI(uri)).Scan(&dbPost).Error; err != nil { 53 - return nil, fmt.Errorf("failed to query post: %w", err) 54 - } 55 - 56 60 if dbPost.NotFound || len(dbPost.Raw) == 0 { 57 61 if autoFetch { 58 62 h.AddMissingRecord(uri, true) ··· 75 79 76 80 var wg sync.WaitGroup 77 81 78 - // Get author DID 79 - 80 - authorDID := extractDIDFromURI(uri) 82 + authorDID := r.Did 81 83 82 84 // Get engagement counts 83 85 var likes, reposts, replies int ··· 121 123 wg.Wait() 122 124 123 125 info := &PostInfo{ 126 + ID: dbPost.ID, 124 127 URI: uri, 125 128 Cid: dbPost.Cid, 126 129 Post: &feedPost,
+10
hydration/utils.go
··· 5 5 "fmt" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/whyrusleeping/market/models" 8 9 ) 9 10 10 11 func (h *Hydrator) NormalizeUri(ctx context.Context, uri string) (string, error) { ··· 27 28 28 29 return fmt.Sprintf("at://%s/%s/%s", did, puri.Collection().String(), puri.RecordKey().String()), nil 29 30 } 31 + 32 + func (h *Hydrator) UriForPost(ctx context.Context, p *models.Post) (string, error) { 33 + r, err := h.backend.GetRepoByID(ctx, p.Author) 34 + if err != nil { 35 + return "", err 36 + } 37 + 38 + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey), nil 39 + }
+11 -11
xrpc/feed/getPostThread.go
··· 15 15 func HandleGetPostThread(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 16 uriParam := c.QueryParam("uri") 17 17 if uriParam == "" { 18 - return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + return c.JSON(http.StatusBadRequest, map[string]any{ 19 19 "error": "InvalidRequest", 20 20 "message": "uri parameter is required", 21 21 }) ··· 27 27 // Hydrate the requested post 28 28 postInfo, err := hydrator.HydratePost(ctx, uriParam, viewer) 29 29 if err != nil { 30 - return c.JSON(http.StatusNotFound, map[string]interface{}{ 30 + return c.JSON(http.StatusNotFound, map[string]any{ 31 31 "error": "NotFound", 32 32 "message": "post not found", 33 33 }) ··· 74 74 uri: uri, 75 75 replyTo: tp.ReplyTo, 76 76 inThread: tp.InThread, 77 - replies: []interface{}{}, 77 + replies: []any{}, 78 78 } 79 79 } 80 80 ··· 98 98 } 99 99 100 100 if rootNode == nil { 101 - return c.JSON(http.StatusNotFound, map[string]interface{}{ 101 + return c.JSON(http.StatusNotFound, map[string]any{ 102 102 "error": "NotFound", 103 103 "message": "thread root not found", 104 104 }) ··· 107 107 // Build the response by traversing the tree 108 108 thread := buildThreadView(ctx, db, rootNode, postsByID, hydrator, viewer, nil) 109 109 110 - return c.JSON(http.StatusOK, map[string]interface{}{ 110 + return c.JSON(http.StatusOK, map[string]any{ 111 111 "thread": thread, 112 112 }) 113 113 } ··· 117 117 uri string 118 118 replyTo uint 119 119 inThread uint 120 - replies []interface{} 120 + replies []any 121 121 } 122 122 123 - func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent interface{}) interface{} { 123 + func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent any) any { 124 124 // Hydrate this post 125 125 postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer) 126 126 if err != nil { 127 127 // Return a notFound post 128 - return map[string]interface{}{ 128 + return map[string]any{ 129 129 "$type": "app.bsky.feed.defs#notFoundPost", 130 130 "uri": node.uri, 131 131 } ··· 134 134 // Hydrate author 135 135 authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 136 136 if err != nil { 137 - return map[string]interface{}{ 137 + return map[string]any{ 138 138 "$type": "app.bsky.feed.defs#notFoundPost", 139 139 "uri": node.uri, 140 140 } 141 141 } 142 142 143 143 // Build replies 144 - var replies []interface{} 144 + var replies []any 145 145 for _, replyNode := range node.replies { 146 146 if rn, ok := replyNode.(*threadPostNode); ok { 147 147 replyView := buildThreadView(ctx, db, rn, allNodes, hydrator, viewer, nil) ··· 150 150 } 151 151 152 152 // Build the thread view post 153 - var repliesForView interface{} 153 + var repliesForView any 154 154 if len(replies) > 0 { 155 155 repliesForView = replies 156 156 }
+135 -133
xrpc/unspecced/getPostThreadV2.go
··· 1 1 package unspecced 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "fmt" 6 7 "log/slog" ··· 11 12 "github.com/labstack/echo/v4" 12 13 "github.com/whyrusleeping/konbini/hydration" 13 14 "github.com/whyrusleeping/konbini/views" 15 + "github.com/whyrusleeping/market/models" 14 16 "gorm.io/gorm" 15 17 ) 16 18 ··· 69 71 }) 70 72 } 71 73 72 - // Determine the root post ID for the thread 73 - rootPostID := anchorPostInfo.InThread 74 - if rootPostID == 0 { 75 - // This post is the root - get its ID 76 - var postID uint 77 - db.Raw(` 78 - SELECT id FROM posts 79 - WHERE author = (SELECT id FROM repos WHERE did = ?) 80 - AND rkey = ? 81 - `, extractDIDFromURI(anchorUri), extractRkeyFromURI(anchorUri)).Scan(&postID) 82 - rootPostID = postID 74 + threadID := anchorPostInfo.InThread 75 + if threadID == 0 { 76 + threadID = anchorPostInfo.ID 83 77 } 84 78 85 - // Query all posts in this thread 86 - type threadPostRow struct { 87 - ID uint 88 - Rkey string 89 - ReplyTo uint 90 - InThread uint 91 - AuthorDid string 79 + var threadPosts []*models.Post 80 + if err := db.Raw("SELECT * FROM posts WHERE in_thread = ? OR id = ?", threadID, anchorPostInfo.ID).Scan(&threadPosts).Error; err != nil { 81 + return err 92 82 } 93 - var threadPosts []threadPostRow 94 - db.Raw(` 95 - SELECT p.id, p.rkey, p.reply_to, p.in_thread, r.did as author_did 96 - FROM posts p 97 - JOIN repos r ON r.id = p.author 98 - WHERE (p.id = ? OR p.in_thread = ?) 99 - AND p.not_found = false 100 - ORDER BY p.created ASC 101 - `, rootPostID, rootPostID).Scan(&threadPosts) 102 83 103 - // Build a map of posts by ID 104 - postsByID := make(map[uint]*threadNode) 105 - for _, tp := range threadPosts { 106 - uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", tp.AuthorDid, tp.Rkey) 107 - postsByID[tp.ID] = &threadNode{ 108 - id: tp.ID, 109 - uri: uri, 110 - replyTo: tp.ReplyTo, 111 - inThread: tp.InThread, 112 - children: []*threadNode{}, 113 - } 114 - } 115 - 116 - // Build parent-child relationships 117 - for _, node := range postsByID { 118 - if node.replyTo != 0 { 119 - parent := postsByID[node.replyTo] 120 - if parent != nil { 121 - parent.children = append(parent.children, node) 122 - } 123 - } 124 - } 125 - 126 - // Find the anchor node 127 - anchorID := uint(0) 128 - for id, node := range postsByID { 129 - if node.uri == anchorUri { 130 - anchorID = id 131 - break 132 - } 133 - } 84 + fmt.Println("GOT THREAD POSTS: ", len(threadPosts)) 134 85 135 - if anchorID == 0 { 136 - return c.JSON(http.StatusNotFound, map[string]interface{}{ 137 - "error": "NotFound", 138 - "message": "anchor post not found in thread", 139 - }) 86 + treeNodes, err := buildThreadTree(ctx, hydrator, db, threadPosts) 87 + if err != nil { 88 + return fmt.Errorf("failed to construct tree: %w", err) 140 89 } 141 90 142 - anchorNode := postsByID[anchorID] 91 + anchor := treeNodes[anchorPostInfo.ID] 143 92 144 93 // Build flat thread items list 145 94 var threadItems []*bsky.UnspeccedGetPostThreadV2_ThreadItem ··· 147 96 148 97 // Add parents if requested 149 98 if above { 150 - parents := collectParents(anchorNode, postsByID) 151 - for i := len(parents) - 1; i >= 0; i-- { 152 - depth := int64(-(len(parents) - i)) 153 - item := buildThreadItem(ctx, hydrator, parents[i], depth, viewer) 99 + parent := anchor.parent 100 + depth := int64(-1) 101 + for parent != nil { 102 + if parent.missing { 103 + fmt.Println("Parent missing: ", depth) 104 + item := &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 105 + Depth: depth, 106 + Uri: parent.uri, 107 + Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{ 108 + UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{ 109 + LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound", 110 + }, 111 + }, 112 + } 113 + 114 + threadItems = append(threadItems, item) 115 + break 116 + } 117 + 118 + item := buildThreadItem(ctx, hydrator, parent, depth, viewer) 154 119 if item != nil { 155 120 threadItems = append(threadItems, item) 156 121 } 122 + 123 + parent = parent.parent 124 + depth-- 157 125 } 158 126 } 159 127 160 128 // Add anchor post (depth 0) 161 - anchorItem := buildThreadItem(ctx, hydrator, anchorNode, 0, viewer) 129 + anchorItem := buildThreadItem(ctx, hydrator, anchor, 0, viewer) 162 130 if anchorItem != nil { 163 131 threadItems = append(threadItems, anchorItem) 164 132 } 165 133 166 134 // Add replies below anchor 167 135 if below > 0 { 168 - replies, hasMore := collectReplies(ctx, hydrator, anchorNode, 1, below, branchingFactor, sort, viewer) 136 + replies, err := collectReplies(ctx, hydrator, anchor, 0, below, branchingFactor, sort, viewer) 137 + if err != nil { 138 + return err 139 + } 169 140 threadItems = append(threadItems, replies...) 170 - hasOtherReplies = hasMore 141 + //hasOtherReplies = hasMore 171 142 } 172 143 173 144 return c.JSON(http.StatusOK, &bsky.UnspeccedGetPostThreadV2_Output{ ··· 176 147 }) 177 148 } 178 149 179 - type threadNode struct { 180 - id uint 181 - uri string 182 - replyTo uint 183 - inThread uint 184 - children []*threadNode 185 - } 186 - 187 - func collectParents(node *threadNode, allNodes map[uint]*threadNode) []*threadNode { 188 - var parents []*threadNode 189 - current := node 190 - for current.replyTo != 0 { 191 - parent := allNodes[current.replyTo] 192 - if parent == nil { 193 - break 194 - } 195 - parents = append(parents, parent) 196 - current = parent 150 + func collectReplies(ctx context.Context, hydrator *hydration.Hydrator, curnode *threadTree, depth int64, below int64, branchingFactor int64, sort string, viewer string) ([]*bsky.UnspeccedGetPostThreadV2_ThreadItem, error) { 151 + if below == 0 { 152 + return nil, nil 197 153 } 198 - return parents 199 - } 200 154 201 - func collectReplies(ctx context.Context, hydrator *hydration.Hydrator, node *threadNode, currentDepth, maxDepth, branchingFactor int64, sort string, viewer string) ([]*bsky.UnspeccedGetPostThreadV2_ThreadItem, bool) { 202 - var items []*bsky.UnspeccedGetPostThreadV2_ThreadItem 203 - hasMore := false 155 + var out []*bsky.UnspeccedGetPostThreadV2_ThreadItem 156 + for _, child := range curnode.children { 157 + out = append(out, buildThreadItem(ctx, hydrator, child, depth+1, viewer)) 158 + if child.missing { 159 + continue 160 + } 204 161 205 - if currentDepth > maxDepth { 206 - return items, false 207 - } 208 - 209 - // Sort children based on sort parameter 210 - children := node.children 211 - // TODO: Actually sort based on the sort parameter (newest/oldest/top) 212 - // For now, just use the order we have 162 + sub, err := collectReplies(ctx, hydrator, child, depth+1, below-1, branchingFactor, sort, viewer) 163 + if err != nil { 164 + return nil, err 165 + } 213 166 214 - // Limit to branchingFactor 215 - limit := int(branchingFactor) 216 - if len(children) > limit { 217 - hasMore = true 218 - children = children[:limit] 167 + out = append(out, sub...) 219 168 } 220 169 221 - for _, child := range children { 222 - item := buildThreadItem(ctx, hydrator, child, currentDepth, viewer) 223 - if item != nil { 224 - items = append(items, item) 170 + return out, nil 171 + } 225 172 226 - // Recursively collect replies 227 - if currentDepth < maxDepth { 228 - childReplies, childHasMore := collectReplies(ctx, hydrator, child, currentDepth+1, maxDepth, branchingFactor, sort, viewer) 229 - items = append(items, childReplies...) 230 - if childHasMore { 231 - hasMore = true 232 - } 233 - } 173 + func buildThreadItem(ctx context.Context, hydrator *hydration.Hydrator, node *threadTree, depth int64, viewer string) *bsky.UnspeccedGetPostThreadV2_ThreadItem { 174 + if node.missing { 175 + return &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 176 + Depth: depth, 177 + Uri: node.uri, 178 + Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{ 179 + UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{ 180 + LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound", 181 + }, 182 + }, 234 183 } 235 184 } 236 185 237 - return items, hasMore 238 - } 239 - 240 - func buildThreadItem(ctx context.Context, hydrator *hydration.Hydrator, node *threadNode, depth int64, viewer string) *bsky.UnspeccedGetPostThreadV2_ThreadItem { 241 186 // Hydrate the post 242 - postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer) 187 + postInfo, err := hydrator.HydratePostDB(ctx, node.uri, node.val, viewer) 243 188 if err != nil { 189 + slog.Error("failed to hydrate post in thread item", "uri", node.uri, "error", err) 244 190 // Return not found item 245 191 return &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 246 192 Depth: depth, ··· 256 202 // Hydrate author 257 203 authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 258 204 if err != nil { 205 + slog.Error("failed to hydrate actor in thread item", "author", postInfo.Author, "error", err) 259 206 return &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 260 207 Depth: depth, 261 208 Uri: node.uri, ··· 319 266 return string(parts) 320 267 } 321 268 322 - func extractRkeyFromURI(uri string) string { 323 - // URI format: at://did:plc:xxx/collection/rkey 324 - if len(uri) < 5 || uri[:5] != "at://" { 325 - return "" 269 + type threadTree struct { 270 + parent *threadTree 271 + children []*threadTree 272 + 273 + val *models.Post 274 + 275 + missing bool 276 + 277 + uri string 278 + cid string 279 + } 280 + 281 + func buildThreadTree(ctx context.Context, hydrator *hydration.Hydrator, db *gorm.DB, posts []*models.Post) (map[uint]*threadTree, error) { 282 + nodes := make(map[uint]*threadTree) 283 + for _, p := range posts { 284 + puri, err := hydrator.UriForPost(ctx, p) 285 + if err != nil { 286 + return nil, err 287 + } 288 + 289 + t := &threadTree{ 290 + val: p, 291 + uri: puri, 292 + } 293 + 294 + nodes[p.ID] = t 326 295 } 327 - // Find last slash 328 - for i := len(uri) - 1; i >= 5; i-- { 329 - if uri[i] == '/' { 330 - return uri[i+1:] 296 + 297 + missing := make(map[uint]*threadTree) 298 + for _, node := range nodes { 299 + if node.val.ReplyTo == 0 { 300 + continue 301 + } 302 + 303 + pnode, ok := nodes[node.val.ReplyTo] 304 + if !ok { 305 + pnode = &threadTree{ 306 + missing: true, 307 + } 308 + missing[node.val.ReplyTo] = pnode 309 + 310 + var bspost bsky.FeedPost 311 + if err := bspost.UnmarshalCBOR(bytes.NewReader(node.val.Raw)); err != nil { 312 + return nil, err 313 + } 314 + 315 + if bspost.Reply == nil || bspost.Reply.Parent == nil { 316 + return nil, fmt.Errorf("node with parent had no parent in object") 317 + } 318 + 319 + pnode.uri = bspost.Reply.Parent.Uri 320 + pnode.cid = bspost.Reply.Parent.Cid 321 + 322 + /* Maybe we could force hydrate these? 323 + hydrator.AddMissingRecord(puri, true) 324 + */ 331 325 } 326 + 327 + pnode.children = append(pnode.children, node) 328 + node.parent = pnode 332 329 } 333 - return "" 330 + 331 + for k, v := range missing { 332 + nodes[k] = v 333 + } 334 + 335 + return nodes, nil 334 336 }