Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 341 lines 8.3 kB view raw
1package constellation 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "net/http" 8 "net/url" 9 "sync" 10 "time" 11) 12 13const ( 14 DefaultBaseURL = "https://constellation.microcosm.blue" 15 DefaultTimeout = 5 * time.Second 16 UserAgent = "Margin (margin.at)" 17) 18 19type Client struct { 20 baseURL string 21 httpClient *http.Client 22} 23 24func NewClient() *Client { 25 return &Client{ 26 baseURL: DefaultBaseURL, 27 httpClient: &http.Client{ 28 Timeout: DefaultTimeout, 29 }, 30 } 31} 32 33func NewClientWithURL(baseURL string) *Client { 34 return &Client{ 35 baseURL: baseURL, 36 httpClient: &http.Client{ 37 Timeout: DefaultTimeout, 38 }, 39 } 40} 41 42type CountResponse struct { 43 Total int `json:"total"` 44} 45 46type Link struct { 47 URI string `json:"uri"` 48 Collection string `json:"collection"` 49 DID string `json:"did"` 50 Path string `json:"path"` 51} 52 53type LinksResponse struct { 54 Links []Link `json:"links"` 55 Cursor string `json:"cursor,omitempty"` 56} 57 58func (c *Client) GetLikeCount(ctx context.Context, subjectURI string) (int, error) { 59 params := url.Values{} 60 params.Set("target", subjectURI) 61 params.Set("collection", "at.margin.like") 62 params.Set("path", ".subject.uri") 63 64 endpoint := fmt.Sprintf("%s/links/count/distinct-dids?%s", c.baseURL, params.Encode()) 65 66 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 67 if err != nil { 68 return 0, fmt.Errorf("failed to create request: %w", err) 69 } 70 req.Header.Set("User-Agent", UserAgent) 71 72 resp, err := c.httpClient.Do(req) 73 if err != nil { 74 return 0, fmt.Errorf("request failed: %w", err) 75 } 76 defer resp.Body.Close() 77 78 if resp.StatusCode != http.StatusOK { 79 return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 80 } 81 82 var countResp CountResponse 83 if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil { 84 return 0, fmt.Errorf("failed to decode response: %w", err) 85 } 86 87 return countResp.Total, nil 88} 89 90func (c *Client) GetReplyCount(ctx context.Context, rootURI string) (int, error) { 91 params := url.Values{} 92 params.Set("target", rootURI) 93 params.Set("collection", "at.margin.reply") 94 params.Set("path", ".root.uri") 95 96 endpoint := fmt.Sprintf("%s/links/count?%s", c.baseURL, params.Encode()) 97 98 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 99 if err != nil { 100 return 0, fmt.Errorf("failed to create request: %w", err) 101 } 102 req.Header.Set("User-Agent", UserAgent) 103 104 resp, err := c.httpClient.Do(req) 105 if err != nil { 106 return 0, fmt.Errorf("request failed: %w", err) 107 } 108 defer resp.Body.Close() 109 110 if resp.StatusCode != http.StatusOK { 111 return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 112 } 113 114 var countResp CountResponse 115 if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil { 116 return 0, fmt.Errorf("failed to decode response: %w", err) 117 } 118 119 return countResp.Total, nil 120} 121 122type CountsResult struct { 123 LikeCount int 124 ReplyCount int 125} 126 127func (c *Client) GetCountsBatch(ctx context.Context, uris []string) (map[string]CountsResult, error) { 128 if len(uris) == 0 { 129 return map[string]CountsResult{}, nil 130 } 131 132 results := make(map[string]CountsResult) 133 var mu sync.Mutex 134 var wg sync.WaitGroup 135 136 semaphore := make(chan struct{}, 10) 137 138 for _, uri := range uris { 139 wg.Add(1) 140 go func(u string) { 141 defer wg.Done() 142 semaphore <- struct{}{} 143 defer func() { <-semaphore }() 144 145 likeCount, _ := c.GetLikeCount(ctx, u) 146 replyCount, _ := c.GetReplyCount(ctx, u) 147 148 mu.Lock() 149 results[u] = CountsResult{ 150 LikeCount: likeCount, 151 ReplyCount: replyCount, 152 } 153 mu.Unlock() 154 }(uri) 155 } 156 157 wg.Wait() 158 return results, nil 159} 160 161func (c *Client) GetAnnotationsForURL(ctx context.Context, targetURL string) ([]Link, error) { 162 params := url.Values{} 163 params.Set("target", targetURL) 164 params.Set("collection", "at.margin.annotation") 165 params.Set("path", ".target.source") 166 167 endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode()) 168 169 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 170 if err != nil { 171 return nil, fmt.Errorf("failed to create request: %w", err) 172 } 173 req.Header.Set("User-Agent", UserAgent) 174 175 resp, err := c.httpClient.Do(req) 176 if err != nil { 177 return nil, fmt.Errorf("request failed: %w", err) 178 } 179 defer resp.Body.Close() 180 181 if resp.StatusCode != http.StatusOK { 182 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 183 } 184 185 var linksResp LinksResponse 186 if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil { 187 return nil, fmt.Errorf("failed to decode response: %w", err) 188 } 189 190 return linksResp.Links, nil 191} 192 193func (c *Client) GetHighlightsForURL(ctx context.Context, targetURL string) ([]Link, error) { 194 params := url.Values{} 195 params.Set("target", targetURL) 196 params.Set("collection", "at.margin.highlight") 197 params.Set("path", ".target.source") 198 199 endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode()) 200 201 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 202 if err != nil { 203 return nil, fmt.Errorf("failed to create request: %w", err) 204 } 205 req.Header.Set("User-Agent", UserAgent) 206 207 resp, err := c.httpClient.Do(req) 208 if err != nil { 209 return nil, fmt.Errorf("request failed: %w", err) 210 } 211 defer resp.Body.Close() 212 213 if resp.StatusCode != http.StatusOK { 214 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 215 } 216 217 var linksResp LinksResponse 218 if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil { 219 return nil, fmt.Errorf("failed to decode response: %w", err) 220 } 221 222 return linksResp.Links, nil 223} 224 225func (c *Client) GetBookmarksForURL(ctx context.Context, targetURL string) ([]Link, error) { 226 params := url.Values{} 227 params.Set("target", targetURL) 228 params.Set("collection", "at.margin.bookmark") 229 params.Set("path", ".source") 230 231 endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode()) 232 233 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 234 if err != nil { 235 return nil, fmt.Errorf("failed to create request: %w", err) 236 } 237 req.Header.Set("User-Agent", UserAgent) 238 239 resp, err := c.httpClient.Do(req) 240 if err != nil { 241 return nil, fmt.Errorf("request failed: %w", err) 242 } 243 defer resp.Body.Close() 244 245 if resp.StatusCode != http.StatusOK { 246 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 247 } 248 249 var linksResp LinksResponse 250 if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil { 251 return nil, fmt.Errorf("failed to decode response: %w", err) 252 } 253 254 return linksResp.Links, nil 255} 256 257func (c *Client) GetAllItemsForURL(ctx context.Context, targetURL string) (annotations, highlights, bookmarks []Link, err error) { 258 var wg sync.WaitGroup 259 var mu sync.Mutex 260 var errs []error 261 262 wg.Add(3) 263 264 go func() { 265 defer wg.Done() 266 links, e := c.GetAnnotationsForURL(ctx, targetURL) 267 mu.Lock() 268 defer mu.Unlock() 269 if e != nil { 270 errs = append(errs, e) 271 } else { 272 annotations = links 273 } 274 }() 275 276 go func() { 277 defer wg.Done() 278 links, e := c.GetHighlightsForURL(ctx, targetURL) 279 mu.Lock() 280 defer mu.Unlock() 281 if e != nil { 282 errs = append(errs, e) 283 } else { 284 highlights = links 285 } 286 }() 287 288 go func() { 289 defer wg.Done() 290 links, e := c.GetBookmarksForURL(ctx, targetURL) 291 mu.Lock() 292 defer mu.Unlock() 293 if e != nil { 294 errs = append(errs, e) 295 } else { 296 bookmarks = links 297 } 298 }() 299 300 wg.Wait() 301 302 if len(errs) > 0 { 303 return annotations, highlights, bookmarks, errs[0] 304 } 305 306 return annotations, highlights, bookmarks, nil 307} 308 309func (c *Client) GetLikers(ctx context.Context, subjectURI string) ([]string, error) { 310 params := url.Values{} 311 params.Set("target", subjectURI) 312 params.Set("collection", "at.margin.like") 313 params.Set("path", ".subject.uri") 314 315 endpoint := fmt.Sprintf("%s/links/distinct-dids?%s", c.baseURL, params.Encode()) 316 317 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 318 if err != nil { 319 return nil, fmt.Errorf("failed to create request: %w", err) 320 } 321 req.Header.Set("User-Agent", UserAgent) 322 323 resp, err := c.httpClient.Do(req) 324 if err != nil { 325 return nil, fmt.Errorf("request failed: %w", err) 326 } 327 defer resp.Body.Close() 328 329 if resp.StatusCode != http.StatusOK { 330 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 331 } 332 333 var result struct { 334 DIDs []string `json:"dids"` 335 } 336 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 337 return nil, fmt.Errorf("failed to decode response: %w", err) 338 } 339 340 return result.DIDs, nil 341}