Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

fix various bugs

+225 -148
+28 -21
backend/internal/api/annotations.go
··· 72 } 73 74 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 75 76 var result *xrpc.CreateRecordOutput 77 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 98 selectorJSONPtr = &selectorStr 99 } 100 101 cid := result.CID 102 did := session.DID 103 annotation := &db.Annotation{ ··· 110 TargetHash: urlHash, 111 TargetTitle: targetTitlePtr, 112 SelectorJSON: selectorJSONPtr, 113 CreatedAt: time.Now(), 114 IndexedAt: time.Now(), 115 } ··· 208 } 209 rkey := parts[2] 210 211 - var selector interface{} = nil 212 - if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" { 213 - json.Unmarshal([]byte(*annotation.SelectorJSON), &selector) 214 - } 215 - 216 tagsJSON := "" 217 if len(req.Tags) > 0 { 218 tagsBytes, _ := json.Marshal(req.Tags) 219 tagsJSON = string(tagsBytes) 220 } 221 222 - record := map[string]interface{}{ 223 - "$type": xrpc.CollectionAnnotation, 224 - "text": req.Text, 225 - "url": annotation.TargetSource, 226 - "createdAt": annotation.CreatedAt.Format(time.RFC3339), 227 - } 228 - if selector != nil { 229 - record["selector"] = selector 230 - } 231 - if len(req.Tags) > 0 { 232 - record["tags"] = req.Tags 233 - } 234 - if annotation.TargetTitle != nil { 235 - record["title"] = *annotation.TargetTitle 236 - } 237 - 238 if annotation.BodyValue != nil { 239 previousContent := *annotation.BodyValue 240 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) ··· 242 243 var result *xrpc.PutRecordOutput 244 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 245 var updateErr error 246 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 247 if updateErr != nil {
··· 72 } 73 74 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 75 + if len(req.Tags) > 0 { 76 + record.Tags = req.Tags 77 + } 78 79 var result *xrpc.CreateRecordOutput 80 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 101 selectorJSONPtr = &selectorStr 102 } 103 104 + var tagsJSONPtr *string 105 + if len(req.Tags) > 0 { 106 + tagsBytes, _ := json.Marshal(req.Tags) 107 + tagsStr := string(tagsBytes) 108 + tagsJSONPtr = &tagsStr 109 + } 110 + 111 cid := result.CID 112 did := session.DID 113 annotation := &db.Annotation{ ··· 120 TargetHash: urlHash, 121 TargetTitle: targetTitlePtr, 122 SelectorJSON: selectorJSONPtr, 123 + TagsJSON: tagsJSONPtr, 124 CreatedAt: time.Now(), 125 IndexedAt: time.Now(), 126 } ··· 219 } 220 rkey := parts[2] 221 222 tagsJSON := "" 223 if len(req.Tags) > 0 { 224 tagsBytes, _ := json.Marshal(req.Tags) 225 tagsJSON = string(tagsBytes) 226 } 227 228 if annotation.BodyValue != nil { 229 previousContent := *annotation.BodyValue 230 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) ··· 232 233 var result *xrpc.PutRecordOutput 234 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 235 + existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 236 + if getErr != nil { 237 + return fmt.Errorf("failed to fetch existing record: %w", getErr) 238 + } 239 + 240 + var record map[string]interface{} 241 + if err := json.Unmarshal(existing.Value, &record); err != nil { 242 + return fmt.Errorf("failed to parse existing record: %w", err) 243 + } 244 + 245 + record["text"] = req.Text 246 + if req.Tags != nil { 247 + record["tags"] = req.Tags 248 + } else { 249 + delete(record, "tags") 250 + } 251 + 252 var updateErr error 253 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 254 if updateErr != nil {
+9 -3
backend/internal/api/collections.go
··· 278 279 enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 280 281 for _, item := range items { 282 enriched := EnrichedCollectionItem{ 283 URI: item.URI, ··· 290 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 291 enriched.Type = "annotation" 292 if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 293 - hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 294 if len(hydrated) > 0 { 295 enriched.Annotation = &hydrated[0] 296 } ··· 298 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 299 enriched.Type = "highlight" 300 if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 301 - hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 302 if len(hydrated) > 0 { 303 enriched.Highlight = &hydrated[0] 304 } ··· 306 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 307 enriched.Type = "bookmark" 308 if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 309 - hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 310 if len(hydrated) > 0 { 311 enriched.Bookmark = &hydrated[0] 312 }
··· 278 279 enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 280 281 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 282 + viewerDID := "" 283 + if err == nil { 284 + viewerDID = session.DID 285 + } 286 + 287 for _, item := range items { 288 enriched := EnrichedCollectionItem{ 289 URI: item.URI, ··· 296 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 297 enriched.Type = "annotation" 298 if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 299 + hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID) 300 if len(hydrated) > 0 { 301 enriched.Annotation = &hydrated[0] 302 } ··· 304 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 305 enriched.Type = "highlight" 306 if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 307 + hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID) 308 if len(hydrated) > 0 { 309 enriched.Highlight = &hydrated[0] 310 } ··· 312 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 313 enriched.Type = "bookmark" 314 if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 315 + hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID) 316 if len(hydrated) > 0 { 317 enriched.Bookmark = &hydrated[0] 318 }
+34 -17
backend/internal/api/handler.go
··· 102 return 103 } 104 105 - enriched, _ := hydrateAnnotations(annotations) 106 107 w.Header().Set("Content-Type", "application/json") 108 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 136 bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 137 collectionItems = []db.CollectionItem{} 138 } 139 } else { 140 annotations, _ = h.db.GetRecentAnnotations(limit, 0) 141 highlights, _ = h.db.GetRecentHighlights(limit, 0) ··· 146 } 147 } 148 149 - authAnnos, _ := hydrateAnnotations(annotations) 150 - authHighs, _ := hydrateHighlights(highlights) 151 - authBooks, _ := hydrateBookmarks(bookmarks) 152 153 - authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems) 154 155 var feed []interface{} 156 for _, a := range authAnnos { ··· 222 } 223 224 if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 225 - if enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}); len(enriched) > 0 { 226 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 227 return 228 } 229 } 230 231 if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 232 - if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 { 233 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 234 return 235 } ··· 238 if strings.Contains(uri, "at.margin.annotation") { 239 highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 240 if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 241 - if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 { 242 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 243 return 244 } ··· 246 } 247 248 if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 249 - if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 { 250 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 251 return 252 } ··· 255 if strings.Contains(uri, "at.margin.annotation") { 256 bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 257 if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 258 - if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 { 259 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 260 return 261 } ··· 284 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 285 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 286 287 - enrichedAnnotations, _ := hydrateAnnotations(annotations) 288 - enrichedHighlights, _ := hydrateHighlights(highlights) 289 290 w.Header().Set("Content-Type", "application/json") 291 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 319 return 320 } 321 322 - enriched, _ := hydrateHighlights(highlights) 323 324 w.Header().Set("Content-Type", "application/json") 325 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 346 return 347 } 348 349 - enriched, _ := hydrateBookmarks(bookmarks) 350 351 w.Header().Set("Content-Type", "application/json") 352 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 371 return 372 } 373 374 - enriched, _ := hydrateAnnotations(annotations) 375 376 w.Header().Set("Content-Type", "application/json") 377 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 397 return 398 } 399 400 - enriched, _ := hydrateHighlights(highlights) 401 402 w.Header().Set("Content-Type", "application/json") 403 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 423 return 424 } 425 426 - enriched, _ := hydrateBookmarks(bookmarks) 427 428 w.Header().Set("Content-Type", "application/json") 429 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 622 w.Header().Set("Content-Type", "application/json") 623 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 624 }
··· 102 return 103 } 104 105 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 106 107 w.Header().Set("Content-Type", "application/json") 108 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 136 bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 137 collectionItems = []db.CollectionItem{} 138 } 139 + } else if creator != "" { 140 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 141 + highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 142 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 143 + collectionItems = []db.CollectionItem{} 144 } else { 145 annotations, _ = h.db.GetRecentAnnotations(limit, 0) 146 highlights, _ = h.db.GetRecentHighlights(limit, 0) ··· 151 } 152 } 153 154 + viewerDID := h.getViewerDID(r) 155 + authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) 156 + authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 157 + authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 158 159 + authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 160 161 var feed []interface{} 162 for _, a := range authAnnos { ··· 228 } 229 230 if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 231 + if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 232 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 233 return 234 } 235 } 236 237 if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 238 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 239 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 240 return 241 } ··· 244 if strings.Contains(uri, "at.margin.annotation") { 245 highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 246 if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 247 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 248 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 249 return 250 } ··· 252 } 253 254 if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 255 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 256 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 257 return 258 } ··· 261 if strings.Contains(uri, "at.margin.annotation") { 262 bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 263 if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 264 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 265 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 266 return 267 } ··· 290 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 291 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 292 293 + enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 294 + enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 295 296 w.Header().Set("Content-Type", "application/json") 297 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 325 return 326 } 327 328 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 329 330 w.Header().Set("Content-Type", "application/json") 331 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 352 return 353 } 354 355 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 356 357 w.Header().Set("Content-Type", "application/json") 358 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 377 return 378 } 379 380 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 381 382 w.Header().Set("Content-Type", "application/json") 383 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 403 return 404 } 405 406 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 407 408 w.Header().Set("Content-Type", "application/json") 409 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 429 return 430 } 431 432 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 433 434 w.Header().Set("Content-Type", "application/json") 435 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 628 w.Header().Set("Content-Type", "application/json") 629 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 630 } 631 + func (h *Handler) getViewerDID(r *http.Request) string { 632 + cookie, err := r.Cookie("margin_session") 633 + if err != nil { 634 + return "" 635 + } 636 + did, _, _, _, _, err := h.db.GetSession(cookie.Value) 637 + if err != nil { 638 + return "" 639 + } 640 + return did 641 + }
+73 -36
backend/internal/api/hydration.go
··· 50 } 51 52 type APIAnnotation struct { 53 - ID string `json:"id"` 54 - CID string `json:"cid"` 55 - Type string `json:"type"` 56 - Motivation string `json:"motivation,omitempty"` 57 - Author Author `json:"creator"` 58 - Body *APIBody `json:"body,omitempty"` 59 - Target APITarget `json:"target"` 60 - Tags []string `json:"tags,omitempty"` 61 - Generator *APIGenerator `json:"generator,omitempty"` 62 - CreatedAt time.Time `json:"created"` 63 - IndexedAt time.Time `json:"indexed"` 64 } 65 66 type APIHighlight struct { 67 - ID string `json:"id"` 68 - Type string `json:"type"` 69 - Author Author `json:"creator"` 70 - Target APITarget `json:"target"` 71 - Color string `json:"color,omitempty"` 72 - Tags []string `json:"tags,omitempty"` 73 - CreatedAt time.Time `json:"created"` 74 - CID string `json:"cid,omitempty"` 75 } 76 77 type APIBookmark struct { 78 - ID string `json:"id"` 79 - Type string `json:"type"` 80 - Author Author `json:"creator"` 81 - Source string `json:"source"` 82 - Title string `json:"title,omitempty"` 83 - Description string `json:"description,omitempty"` 84 - Tags []string `json:"tags,omitempty"` 85 - CreatedAt time.Time `json:"created"` 86 - CID string `json:"cid,omitempty"` 87 } 88 89 type APIReply struct { ··· 132 ReadAt *time.Time `json:"readAt,omitempty"` 133 } 134 135 - func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) { 136 if len(annotations) == 0 { 137 return []APIAnnotation{}, nil 138 } ··· 197 CreatedAt: a.CreatedAt, 198 IndexedAt: a.IndexedAt, 199 } 200 } 201 202 return result, nil 203 } 204 205 - func hydrateHighlights(highlights []db.Highlight) ([]APIHighlight, error) { 206 if len(highlights) == 0 { 207 return []APIHighlight{}, nil 208 } ··· 251 CreatedAt: h.CreatedAt, 252 CID: cid, 253 } 254 } 255 256 return result, nil 257 } 258 259 - func hydrateBookmarks(bookmarks []db.Bookmark) ([]APIBookmark, error) { 260 if len(bookmarks) == 0 { 261 return []APIBookmark{}, nil 262 } ··· 295 Tags: tags, 296 CreatedAt: b.CreatedAt, 297 CID: cid, 298 } 299 } 300 ··· 439 return result, nil 440 } 441 442 - func hydrateCollectionItems(database *db.DB, items []db.CollectionItem) ([]APICollectionItem, error) { 443 if len(items) == 0 { 444 return []APICollectionItem{}, nil 445 } ··· 479 480 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 481 if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 482 - hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 483 if len(hydrated) > 0 { 484 apiItem.Annotation = &hydrated[0] 485 } 486 } 487 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 488 if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 489 - hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 490 if len(hydrated) > 0 { 491 apiItem.Highlight = &hydrated[0] 492 } 493 } 494 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 495 if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 496 - hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 497 if len(hydrated) > 0 { 498 apiItem.Bookmark = &hydrated[0] 499 } else { 500 log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 501 } 502 } else { 503 - log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 504 } 505 } else { 506 log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI)
··· 50 } 51 52 type APIAnnotation struct { 53 + ID string `json:"id"` 54 + CID string `json:"cid"` 55 + Type string `json:"type"` 56 + Motivation string `json:"motivation,omitempty"` 57 + Author Author `json:"creator"` 58 + Body *APIBody `json:"body,omitempty"` 59 + Target APITarget `json:"target"` 60 + Tags []string `json:"tags,omitempty"` 61 + Generator *APIGenerator `json:"generator,omitempty"` 62 + CreatedAt time.Time `json:"created"` 63 + IndexedAt time.Time `json:"indexed"` 64 + LikeCount int `json:"likeCount"` 65 + ReplyCount int `json:"replyCount"` 66 + ViewerHasLiked bool `json:"viewerHasLiked"` 67 } 68 69 type APIHighlight struct { 70 + ID string `json:"id"` 71 + Type string `json:"type"` 72 + Author Author `json:"creator"` 73 + Target APITarget `json:"target"` 74 + Color string `json:"color,omitempty"` 75 + Tags []string `json:"tags,omitempty"` 76 + CreatedAt time.Time `json:"created"` 77 + CID string `json:"cid,omitempty"` 78 + LikeCount int `json:"likeCount"` 79 + ReplyCount int `json:"replyCount"` 80 + ViewerHasLiked bool `json:"viewerHasLiked"` 81 } 82 83 type APIBookmark struct { 84 + ID string `json:"id"` 85 + Type string `json:"type"` 86 + Author Author `json:"creator"` 87 + Source string `json:"source"` 88 + Title string `json:"title,omitempty"` 89 + Description string `json:"description,omitempty"` 90 + Tags []string `json:"tags,omitempty"` 91 + CreatedAt time.Time `json:"created"` 92 + CID string `json:"cid,omitempty"` 93 + LikeCount int `json:"likeCount"` 94 + ReplyCount int `json:"replyCount"` 95 + ViewerHasLiked bool `json:"viewerHasLiked"` 96 } 97 98 type APIReply struct { ··· 141 ReadAt *time.Time `json:"readAt,omitempty"` 142 } 143 144 + func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) { 145 if len(annotations) == 0 { 146 return []APIAnnotation{}, nil 147 } ··· 206 CreatedAt: a.CreatedAt, 207 IndexedAt: a.IndexedAt, 208 } 209 + 210 + if database != nil { 211 + result[i].LikeCount, _ = database.GetLikeCount(a.URI) 212 + result[i].ReplyCount, _ = database.GetReplyCount(a.URI) 213 + if viewerDID != "" { 214 + if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil { 215 + result[i].ViewerHasLiked = true 216 + } 217 + } 218 + } 219 } 220 221 return result, nil 222 } 223 224 + func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID string) ([]APIHighlight, error) { 225 if len(highlights) == 0 { 226 return []APIHighlight{}, nil 227 } ··· 270 CreatedAt: h.CreatedAt, 271 CID: cid, 272 } 273 + 274 + if database != nil { 275 + result[i].LikeCount, _ = database.GetLikeCount(h.URI) 276 + result[i].ReplyCount, _ = database.GetReplyCount(h.URI) 277 + if viewerDID != "" { 278 + if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil { 279 + result[i].ViewerHasLiked = true 280 + } 281 + } 282 + } 283 } 284 285 return result, nil 286 } 287 288 + func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string) ([]APIBookmark, error) { 289 if len(bookmarks) == 0 { 290 return []APIBookmark{}, nil 291 } ··· 324 Tags: tags, 325 CreatedAt: b.CreatedAt, 326 CID: cid, 327 + } 328 + if database != nil { 329 + result[i].LikeCount, _ = database.GetLikeCount(b.URI) 330 + result[i].ReplyCount, _ = database.GetReplyCount(b.URI) 331 + if viewerDID != "" { 332 + if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil { 333 + result[i].ViewerHasLiked = true 334 + } 335 + } 336 } 337 } 338 ··· 477 return result, nil 478 } 479 480 + func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDID string) ([]APICollectionItem, error) { 481 if len(items) == 0 { 482 return []APICollectionItem{}, nil 483 } ··· 517 518 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 519 if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 520 + hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID) 521 if len(hydrated) > 0 { 522 apiItem.Annotation = &hydrated[0] 523 } 524 } 525 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 526 if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 527 + hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID) 528 if len(hydrated) > 0 { 529 apiItem.Highlight = &hydrated[0] 530 } 531 } 532 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 533 if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 534 + hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID) 535 if len(hydrated) > 0 { 536 apiItem.Bookmark = &hydrated[0] 537 } else { 538 log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 539 } 540 } else { 541 } 542 } else { 543 log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI)
+6
backend/internal/db/queries.go
··· 634 return count, err 635 } 636 637 func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { 638 var like Like 639 err := db.QueryRow(db.Rebind(`
··· 634 return count, err 635 } 636 637 + func (db *DB) GetReplyCount(rootURI string) (int, error) { 638 + var count int 639 + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) 640 + return count, err 641 + } 642 + 643 func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { 644 var like Like 645 err := db.QueryRow(db.Rebind(`
+18
web/src/api/client.js
··· 314 tags: item.tags || [], 315 createdAt: item.createdAt || item.created, 316 cid: item.cid || item.CID, 317 }; 318 } 319 ··· 328 tags: item.tags || [], 329 createdAt: item.createdAt || item.created, 330 cid: item.cid || item.CID, 331 }; 332 } 333 ··· 343 tags: item.tags || [], 344 createdAt: item.createdAt || item.created, 345 cid: item.cid || item.CID, 346 }; 347 } 348 ··· 358 tags: item.tags || [], 359 createdAt: item.createdAt || item.created, 360 cid: item.cid || item.CID, 361 }; 362 } 363 ··· 371 color: highlight.color, 372 tags: highlight.tags || [], 373 createdAt: highlight.createdAt || highlight.created, 374 }; 375 } 376 ··· 383 description: bookmark.description, 384 tags: bookmark.tags || [], 385 createdAt: bookmark.createdAt || bookmark.created, 386 }; 387 } 388
··· 314 tags: item.tags || [], 315 createdAt: item.createdAt || item.created, 316 cid: item.cid || item.CID, 317 + likeCount: item.likeCount || 0, 318 + replyCount: item.replyCount || 0, 319 + viewerHasLiked: item.viewerHasLiked || false, 320 }; 321 } 322 ··· 331 tags: item.tags || [], 332 createdAt: item.createdAt || item.created, 333 cid: item.cid || item.CID, 334 + likeCount: item.likeCount || 0, 335 + replyCount: item.replyCount || 0, 336 + viewerHasLiked: item.viewerHasLiked || false, 337 }; 338 } 339 ··· 349 tags: item.tags || [], 350 createdAt: item.createdAt || item.created, 351 cid: item.cid || item.CID, 352 + likeCount: item.likeCount || 0, 353 + replyCount: item.replyCount || 0, 354 + viewerHasLiked: item.viewerHasLiked || false, 355 }; 356 } 357 ··· 367 tags: item.tags || [], 368 createdAt: item.createdAt || item.created, 369 cid: item.cid || item.CID, 370 + likeCount: item.likeCount || 0, 371 + replyCount: item.replyCount || 0, 372 + viewerHasLiked: item.viewerHasLiked || false, 373 }; 374 } 375 ··· 383 color: highlight.color, 384 tags: highlight.tags || [], 385 createdAt: highlight.createdAt || highlight.created, 386 + likeCount: highlight.likeCount || 0, 387 + replyCount: highlight.replyCount || 0, 388 + viewerHasLiked: highlight.viewerHasLiked || false, 389 }; 390 } 391 ··· 398 description: bookmark.description, 399 tags: bookmark.tags || [], 400 createdAt: bookmark.createdAt || bookmark.created, 401 + likeCount: bookmark.likeCount || 0, 402 + replyCount: bookmark.replyCount || 0, 403 + viewerHasLiked: bookmark.viewerHasLiked || false, 404 }; 405 } 406
+16 -44
web/src/components/AnnotationCard.jsx
··· 67 const { user, login } = useAuth(); 68 const data = normalizeAnnotation(annotation); 69 70 - const [likeCount, setLikeCount] = useState(0); 71 - const [isLiked, setIsLiked] = useState(false); 72 const [deleting, setDeleting] = useState(false); 73 const [isEditing, setIsEditing] = useState(false); 74 const [editText, setEditText] = useState(data.text || ""); ··· 80 const [loadingHistory, setLoadingHistory] = useState(false); 81 82 const [replies, setReplies] = useState([]); 83 - const [replyCount, setReplyCount] = useState(0); 84 const [showReplies, setShowReplies] = useState(false); 85 const [replyingTo, setReplyingTo] = useState(null); 86 const [replyText, setReplyText] = useState(""); ··· 90 91 const [hasEditHistory, setHasEditHistory] = useState(false); 92 93 - useEffect(() => { 94 - let mounted = true; 95 - async function fetchData() { 96 - try { 97 - const repliesRes = await getReplies(data.uri); 98 - if (mounted && repliesRes.items) { 99 - setReplies(repliesRes.items); 100 - setReplyCount(repliesRes.items.length); 101 - } 102 - 103 - const likeRes = await getLikeCount(data.uri); 104 - if (mounted) { 105 - if (likeRes.count !== undefined) { 106 - setLikeCount(likeRes.count); 107 - } 108 - if (likeRes.liked !== undefined) { 109 - setIsLiked(likeRes.liked); 110 - } 111 - } 112 - 113 - if (!data.color && !data.description) { 114 - try { 115 - const history = await getEditHistory(data.uri); 116 - if (mounted && history && history.length > 0) { 117 - setHasEditHistory(true); 118 - } 119 - } catch {} 120 - } 121 - } catch (err) { 122 - console.error("Failed to fetch data:", err); 123 - } 124 - } 125 - if (data.uri) { 126 - fetchData(); 127 - } 128 - return () => { 129 - mounted = false; 130 - }; 131 - }, [data.uri]); 132 133 const fetchHistory = async () => { 134 if (showHistory) { ··· 421 rel="noopener noreferrer" 422 className="annotation-highlight" 423 style={{ 424 - borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 425 }} 426 > 427 <mark>"{highlightedText}"</mark> ··· 497 </button> 498 <button 499 className={`annotation-action ${showReplies ? "active" : ""}`} 500 - onClick={() => setShowReplies(!showReplies)} 501 > 502 <MessageIcon size={16} /> 503 <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
··· 67 const { user, login } = useAuth(); 68 const data = normalizeAnnotation(annotation); 69 70 + const [likeCount, setLikeCount] = useState(data.likeCount || 0); 71 + const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); 72 const [deleting, setDeleting] = useState(false); 73 const [isEditing, setIsEditing] = useState(false); 74 const [editText, setEditText] = useState(data.text || ""); ··· 80 const [loadingHistory, setLoadingHistory] = useState(false); 81 82 const [replies, setReplies] = useState([]); 83 + const [replyCount, setReplyCount] = useState(data.replyCount || 0); 84 const [showReplies, setShowReplies] = useState(false); 85 const [replyingTo, setReplyingTo] = useState(null); 86 const [replyText, setReplyText] = useState(""); ··· 90 91 const [hasEditHistory, setHasEditHistory] = useState(false); 92 93 + useEffect(() => {}, []); 94 95 const fetchHistory = async () => { 96 if (showHistory) { ··· 383 rel="noopener noreferrer" 384 className="annotation-highlight" 385 style={{ 386 + borderLeftColor: data.color || "#f59e0b", 387 }} 388 > 389 <mark>"{highlightedText}"</mark> ··· 459 </button> 460 <button 461 className={`annotation-action ${showReplies ? "active" : ""}`} 462 + onClick={async () => { 463 + if (!showReplies && replies.length === 0) { 464 + try { 465 + const res = await getReplies(data.uri); 466 + if (res.items) setReplies(res.items); 467 + } catch (err) { 468 + console.error("Failed to load replies:", err); 469 + } 470 + } 471 + setShowReplies(!showReplies); 472 + }} 473 > 474 <MessageIcon size={16} /> 475 <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
+35 -9
web/src/pages/Feed.jsx
··· 12 export default function Feed() { 13 const [searchParams, setSearchParams] = useSearchParams(); 14 const tagFilter = searchParams.get("tag"); 15 const [annotations, setAnnotations] = useState([]); 16 const [loading, setLoading] = useState(true); 17 const [error, setError] = useState(null); 18 - const [filter, setFilter] = useState("all"); 19 const [collectionModalState, setCollectionModalState] = useState({ 20 isOpen: false, 21 uri: null, ··· 28 try { 29 setLoading(true); 30 let creatorDid = ""; 31 - if (filter === "my-tags" && user?.did) { 32 - creatorDid = user.did; 33 } 34 35 const data = await getAnnotationFeed( ··· 83 Filtering by tag: <strong>#{tagFilter}</strong> 84 </span> 85 <button 86 - onClick={() => setSearchParams({})} 87 className="btn btn-sm" 88 style={{ padding: "2px 8px", fontSize: "0.8rem" }} 89 > ··· 97 <div className="feed-filters"> 98 <button 99 className={`filter-tab ${filter === "all" ? "active" : ""}`} 100 - onClick={() => setFilter("all")} 101 > 102 All 103 </button> 104 {user && ( 105 <button 106 className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 107 - onClick={() => setFilter("my-tags")} 108 > 109 My Feed 110 </button> 111 )} 112 <button 113 className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 114 - onClick={() => setFilter("commenting")} 115 > 116 Annotations 117 </button> 118 <button 119 className={`filter-tab ${filter === "highlighting" ? "active" : ""}`} 120 - onClick={() => setFilter("highlighting")} 121 > 122 Highlights 123 </button> 124 <button 125 className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`} 126 - onClick={() => setFilter("bookmarking")} 127 > 128 Bookmarks 129 </button>
··· 12 export default function Feed() { 13 const [searchParams, setSearchParams] = useSearchParams(); 14 const tagFilter = searchParams.get("tag"); 15 + const filter = searchParams.get("filter") || "all"; 16 + 17 const [annotations, setAnnotations] = useState([]); 18 const [loading, setLoading] = useState(true); 19 const [error, setError] = useState(null); 20 + 21 + const updateFilter = (newFilter) => { 22 + setSearchParams( 23 + (prev) => { 24 + const next = new URLSearchParams(prev); 25 + next.set("filter", newFilter); 26 + return next; 27 + }, 28 + { replace: true }, 29 + ); 30 + }; 31 + 32 const [collectionModalState, setCollectionModalState] = useState({ 33 isOpen: false, 34 uri: null, ··· 41 try { 42 setLoading(true); 43 let creatorDid = ""; 44 + 45 + if (filter === "my-tags") { 46 + if (user?.did) { 47 + creatorDid = user.did; 48 + } else { 49 + setAnnotations([]); 50 + setLoading(false); 51 + return; 52 + } 53 } 54 55 const data = await getAnnotationFeed( ··· 103 Filtering by tag: <strong>#{tagFilter}</strong> 104 </span> 105 <button 106 + onClick={() => 107 + setSearchParams((prev) => { 108 + const next = new URLSearchParams(prev); 109 + next.delete("tag"); 110 + return next; 111 + }) 112 + } 113 className="btn btn-sm" 114 style={{ padding: "2px 8px", fontSize: "0.8rem" }} 115 > ··· 123 <div className="feed-filters"> 124 <button 125 className={`filter-tab ${filter === "all" ? "active" : ""}`} 126 + onClick={() => updateFilter("all")} 127 > 128 All 129 </button> 130 {user && ( 131 <button 132 className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 133 + onClick={() => updateFilter("my-tags")} 134 > 135 My Feed 136 </button> 137 )} 138 <button 139 className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 140 + onClick={() => updateFilter("commenting")} 141 > 142 Annotations 143 </button> 144 <button 145 className={`filter-tab ${filter === "highlighting" ? "active" : ""}`} 146 + onClick={() => updateFilter("highlighting")} 147 > 148 Highlights 149 </button> 150 <button 151 className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`} 152 + onClick={() => updateFilter("bookmarking")} 153 > 154 Bookmarks 155 </button>
+5 -1
web/src/pages/New.jsx
··· 84 85 <div className="card"> 86 <Composer 87 - url={url || initialUrl} 88 selector={initialSelector} 89 onSuccess={handleSuccess} 90 onCancel={() => navigate(-1)}
··· 84 85 <div className="card"> 86 <Composer 87 + url={ 88 + (url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl) 89 + ? `https://${url || initialUrl}` 90 + : url || initialUrl 91 + } 92 selector={initialSelector} 93 onSuccess={handleSuccess} 94 onCancel={() => navigate(-1)}
+1 -17
web/src/pages/Profile.jsx
··· 130 </div> 131 ); 132 } 133 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 134 - } 135 - if (activeTab === "bookmarks") { 136 - if (bookmarks.length === 0) { 137 - return ( 138 - <div className="empty-state"> 139 - <div className="empty-state-icon"> 140 - <BookmarkIcon size={32} /> 141 - </div> 142 - <h3 className="empty-state-title">No bookmarks</h3> 143 - <p className="empty-state-text"> 144 - This user hasn't bookmarked any pages. 145 - </p> 146 - </div> 147 - ); 148 - } 149 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 150 } 151 152 if (activeTab === "collections") {
··· 130 </div> 131 ); 132 } 133 + return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />); 134 } 135 136 if (activeTab === "collections") {