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}