+12
-9
hydration/post.go
+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
+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
+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
+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
}