A locally focused bluesky appview
1package feed
2
3import (
4 "context"
5 "net/http"
6 "strconv"
7 "time"
8
9 "github.com/labstack/echo/v4"
10 "github.com/whyrusleeping/konbini/hydration"
11 "go.opentelemetry.io/otel"
12 "gorm.io/gorm"
13)
14
15var tracer = otel.Tracer("xrpc/feed")
16
17// HandleGetTimeline implements app.bsky.feed.getTimeline
18func HandleGetTimeline(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
19 ctx := c.Request().Context()
20 ctx, span := tracer.Start(ctx, "getTimeline")
21 defer span.End()
22
23 viewer := getUserDID(c)
24 if viewer == "" {
25 return c.JSON(http.StatusUnauthorized, map[string]any{
26 "error": "AuthenticationRequired",
27 "message": "authentication required",
28 })
29 }
30
31 // Parse limit
32 limit := 50
33 if limitParam := c.QueryParam("limit"); limitParam != "" {
34 if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
35 limit = l
36 }
37 }
38
39 // Parse cursor (timestamp)
40 cursor := time.Now()
41 if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
42 if t, err := time.Parse(time.RFC3339, cursorParam); err == nil {
43 cursor = t
44 }
45 }
46
47 // Get viewer's repo ID
48 var viewerRepoID uint
49 if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&viewerRepoID).Error; err != nil {
50 return c.JSON(http.StatusInternalServerError, map[string]any{
51 "error": "InternalError",
52 "message": "failed to load viewer",
53 })
54 }
55
56 // Query posts from followed users
57
58 rows, err := getTimelinePosts(ctx, db, viewerRepoID, cursor, limit)
59 if err != nil {
60 return c.JSON(http.StatusInternalServerError, map[string]any{
61 "error": "InternalError",
62 "message": "failed to query timeline",
63 })
64 }
65
66 // Hydrate posts
67 feed := hydratePostRows(ctx, hydrator, viewer, rows)
68
69 // Generate next cursor
70 var nextCursor string
71 if len(rows) > 0 {
72 // Get the created time of the last post
73 var lastCreated time.Time
74 lastURI := rows[len(rows)-1].URI
75 postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer)
76 if err == nil && postInfo.Post != nil {
77 t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt)
78 if err == nil {
79 lastCreated = t
80 nextCursor = lastCreated.Format(time.RFC3339)
81 }
82 }
83 }
84
85 return c.JSON(http.StatusOK, map[string]any{
86 "feed": feed,
87 "cursor": nextCursor,
88 })
89}
90
91func getTimelinePosts(ctx context.Context, db *gorm.DB, uid uint, cursor time.Time, limit int) ([]postRow, error) {
92 ctx, span := tracer.Start(ctx, "getTimelineQuery")
93 defer span.End()
94
95 var rows []postRow
96 err := db.Raw(`
97 SELECT
98 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
99 p.author as author_id
100 FROM posts p
101 JOIN repos r ON r.id = p.author
102 WHERE p.reply_to = 0
103 AND p.author IN (SELECT subject FROM follows WHERE author = ?)
104 AND p.created < ?
105 AND p.not_found = false
106 ORDER BY p.created DESC
107 LIMIT ?
108 `, uid, cursor, limit).Scan(&rows).Error
109
110 if err != nil {
111 return nil, err
112 }
113 return rows, nil
114}