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