A community based topic aggregation platform built on atproto
1package timeline
2
3import (
4 "Coves/internal/api/handlers/common"
5 "Coves/internal/api/middleware"
6 "Coves/internal/core/posts"
7 "Coves/internal/core/timeline"
8 "Coves/internal/core/votes"
9 "encoding/json"
10 "log"
11 "net/http"
12 "strconv"
13 "strings"
14)
15
16// GetTimelineHandler handles timeline feed retrieval
17type GetTimelineHandler struct {
18 service timeline.Service
19 voteService votes.Service
20}
21
22// NewGetTimelineHandler creates a new timeline handler
23func NewGetTimelineHandler(service timeline.Service, voteService votes.Service) *GetTimelineHandler {
24 return &GetTimelineHandler{
25 service: service,
26 voteService: voteService,
27 }
28}
29
30// HandleGetTimeline retrieves posts from all communities the user subscribes to
31// GET /xrpc/social.coves.feed.getTimeline?sort=hot&limit=15&cursor=...
32// Requires authentication (user must be logged in)
33func (h *GetTimelineHandler) HandleGetTimeline(w http.ResponseWriter, r *http.Request) {
34 if r.Method != http.MethodGet {
35 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
36 return
37 }
38
39 // Extract authenticated user DID from context (set by RequireAuth middleware)
40 userDID := middleware.GetUserDID(r)
41 if userDID == "" || !strings.HasPrefix(userDID, "did:") {
42 writeError(w, http.StatusUnauthorized, "AuthenticationRequired", "User must be authenticated to view timeline")
43 return
44 }
45
46 // Parse query parameters
47 req, err := h.parseRequest(r, userDID)
48 if err != nil {
49 writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
50 return
51 }
52
53 // Get timeline
54 response, err := h.service.GetTimeline(r.Context(), req)
55 if err != nil {
56 handleServiceError(w, err)
57 return
58 }
59
60 // Populate viewer vote state if authenticated
61 common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed)
62
63 // Transform blob refs to URLs for all posts
64 for _, feedPost := range response.Feed {
65 if feedPost.Post != nil {
66 posts.TransformBlobRefsToURLs(feedPost.Post)
67 }
68 }
69
70 // Return feed
71 w.Header().Set("Content-Type", "application/json")
72 w.WriteHeader(http.StatusOK)
73 if err := json.NewEncoder(w).Encode(response); err != nil {
74 // Log encoding errors but don't return error response (headers already sent)
75 log.Printf("ERROR: Failed to encode timeline response: %v", err)
76 }
77}
78
79// parseRequest parses query parameters into GetTimelineRequest
80func (h *GetTimelineHandler) parseRequest(r *http.Request, userDID string) (timeline.GetTimelineRequest, error) {
81 req := timeline.GetTimelineRequest{
82 UserDID: userDID, // Set from authenticated context
83 }
84
85 // Optional: sort (default: hot)
86 req.Sort = r.URL.Query().Get("sort")
87 if req.Sort == "" {
88 req.Sort = "hot"
89 }
90
91 // Optional: timeframe (default: day for top sort)
92 req.Timeframe = r.URL.Query().Get("timeframe")
93 if req.Timeframe == "" && req.Sort == "top" {
94 req.Timeframe = "day"
95 }
96
97 // Optional: limit (default: 15, max: 50)
98 req.Limit = 15
99 if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
100 if limit, err := strconv.Atoi(limitStr); err == nil {
101 req.Limit = limit
102 }
103 }
104
105 // Optional: cursor
106 if cursor := r.URL.Query().Get("cursor"); cursor != "" {
107 req.Cursor = &cursor
108 }
109
110 return req, nil
111}