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

Implement Semble cards and collections to Margin

+1396 -268
+71 -43
backend/internal/api/collections.go
··· 1 package api 2 3 import ( 4 "encoding/json" 5 "log" 6 "net/http" 7 "net/url" ··· 286 return 287 } 288 289 - enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 290 291 session, err := s.refresher.GetSessionWithAutoRefresh(r) 292 viewerDID := "" ··· 294 viewerDID = session.DID 295 } 296 297 - for _, item := range items { 298 - enriched := EnrichedCollectionItem{ 299 - URI: item.URI, 300 - CollectionURI: item.CollectionURI, 301 - AnnotationURI: item.AnnotationURI, 302 - Position: item.Position, 303 - CreatedAt: item.CreatedAt, 304 - } 305 - 306 - if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 307 - enriched.Type = "annotation" 308 - if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 309 - hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID) 310 - if len(hydrated) > 0 { 311 - enriched.Annotation = &hydrated[0] 312 - } 313 - } 314 - } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 315 - enriched.Type = "highlight" 316 - if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 317 - hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID) 318 - if len(hydrated) > 0 { 319 - enriched.Highlight = &hydrated[0] 320 - } 321 - } 322 - } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 323 - enriched.Type = "bookmark" 324 - if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 325 - hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID) 326 - if len(hydrated) > 0 { 327 - enriched.Bookmark = &hydrated[0] 328 - } 329 - } else { 330 - log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 331 - } 332 - } else { 333 - log.Printf("Unknown annotation type for URI: %s\n", item.AnnotationURI) 334 - } 335 - 336 - if enriched.Annotation != nil || enriched.Highlight != nil || enriched.Bookmark != nil { 337 - enrichedItems = append(enrichedItems, enriched) 338 - } 339 } 340 341 w.Header().Set("Content-Type", "application/json") ··· 466 w.WriteHeader(http.StatusOK) 467 json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 468 }
··· 1 package api 2 3 import ( 4 + "context" 5 "encoding/json" 6 + "fmt" 7 "log" 8 "net/http" 9 "net/url" ··· 288 return 289 } 290 291 + var sembleURIs []string 292 + for _, item := range items { 293 + if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 294 + sembleURIs = append(sembleURIs, item.AnnotationURI) 295 + } 296 + } 297 + 298 + if len(sembleURIs) > 0 { 299 + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 300 + defer cancel() 301 + ensureSembleCardsIndexed(ctx, s.db, sembleURIs) 302 + } 303 304 session, err := s.refresher.GetSessionWithAutoRefresh(r) 305 viewerDID := "" ··· 307 viewerDID = session.DID 308 } 309 310 + enrichedItems, err := hydrateCollectionItems(s.db, items, viewerDID) 311 + if err != nil { 312 + log.Printf("Hydration error: %v", err) 313 + enrichedItems = []APICollectionItem{} 314 } 315 316 w.Header().Set("Content-Type", "application/json") ··· 441 w.WriteHeader(http.StatusOK) 442 json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 443 } 444 + 445 + func (s *CollectionService) GetCollection(w http.ResponseWriter, r *http.Request) { 446 + uri := r.URL.Query().Get("uri") 447 + if uri == "" { 448 + http.Error(w, "URI required", http.StatusBadRequest) 449 + return 450 + } 451 + 452 + collection, err := s.db.GetCollectionByURI(uri) 453 + if err != nil { 454 + if strings.Contains(uri, "at.margin.collection") && strings.HasPrefix(uri, "at://") { 455 + uriWithoutScheme := strings.TrimPrefix(uri, "at://") 456 + parts := strings.Split(uriWithoutScheme, "/") 457 + if len(parts) >= 3 { 458 + did := parts[0] 459 + rkey := parts[len(parts)-1] 460 + sembleURI := fmt.Sprintf("at://%s/network.cosmik.collection/%s", did, rkey) 461 + 462 + collection, err = s.db.GetCollectionByURI(sembleURI) 463 + } 464 + } 465 + } 466 + 467 + if err != nil || collection == nil { 468 + http.Error(w, "Collection not found", http.StatusNotFound) 469 + return 470 + } 471 + 472 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 473 + creator := profiles[collection.AuthorDID] 474 + 475 + icon := "" 476 + if collection.Icon != nil { 477 + icon = *collection.Icon 478 + } 479 + desc := "" 480 + if collection.Description != nil { 481 + desc = *collection.Description 482 + } 483 + 484 + apiCollection := APICollection{ 485 + URI: collection.URI, 486 + Name: collection.Name, 487 + Description: desc, 488 + Icon: icon, 489 + Creator: creator, 490 + CreatedAt: collection.CreatedAt, 491 + IndexedAt: collection.IndexedAt, 492 + } 493 + 494 + w.Header().Set("Content-Type", "application/json") 495 + json.NewEncoder(w).Encode(apiCollection) 496 + }
+207 -68
backend/internal/api/handler.go
··· 1 package api 2 3 import ( 4 "encoding/json" 5 "io" 6 "log" 7 "net/http" ··· 62 r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) 63 r.Delete("/collections/items", collectionService.RemoveCollectionItem) 64 r.Get("/collections/containing", collectionService.GetAnnotationCollections) 65 r.Post("/sync", h.SyncAll) 66 67 r.Get("/targets", h.GetByTarget) ··· 138 limit := parseIntParam(r, "limit", 50) 139 tag := r.URL.Query().Get("tag") 140 creator := r.URL.Query().Get("creator") 141 142 viewerDID := h.getViewerDID(r) 143 144 - if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "")) { 145 if creator == viewerDID { 146 h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 147 return ··· 154 var collectionItems []db.CollectionItem 155 var err error 156 157 if tag != "" { 158 if creator != "" { 159 - annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 160 - highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 161 - bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 162 collectionItems = []db.CollectionItem{} 163 } else { 164 - annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 165 - highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 166 - bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 167 collectionItems = []db.CollectionItem{} 168 } 169 } else if creator != "" { 170 - annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 171 - highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 172 - bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 173 collectionItems = []db.CollectionItem{} 174 } else { 175 - annotations, _ = h.db.GetRecentAnnotations(limit, 0) 176 - highlights, _ = h.db.GetRecentHighlights(limit, 0) 177 - bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 178 - collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 179 - if err != nil { 180 - log.Printf("Error fetching collection items: %v\n", err) 181 } 182 } 183 ··· 185 authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 186 authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 187 188 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 189 190 var feed []interface{} ··· 201 feed = append(feed, ci) 202 } 203 204 - sortFeed(feed) 205 206 if len(feed) > limit { 207 feed = feed[:limit] ··· 288 h.db.CreateBookmark(&b) 289 } 290 }() 291 292 authAnnos, _ := hydrateAnnotations(h.db, annotations, did) 293 authHighs, _ := hydrateHighlights(h.db, highlights, did) 294 authBooks, _ := hydrateBookmarks(h.db, bookmarks, did) 295 296 var feed []interface{} 297 for _, a := range authAnnos { ··· 302 } 303 for _, b := range authBooks { 304 feed = append(feed, b) 305 } 306 307 sortFeed(feed) ··· 363 } 364 } 365 366 func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 367 uri := r.URL.Query().Get("uri") 368 if uri == "" { ··· 400 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 401 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 402 return 403 } 404 } 405 } ··· 530 viewerDID := h.getViewerDID(r) 531 532 if offset == 0 && viewerDID != "" && did == viewerDID { 533 - raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit) 534 - if err == nil { 535 - for _, r := range raw { 536 - if a, ok := r.(*db.Annotation); ok { 537 - annotations = append(annotations, *a) 538 - } 539 } 540 - go func() { 541 - for _, a := range annotations { 542 - h.db.CreateAnnotation(&a) 543 - } 544 - }() 545 - } else { 546 - log.Printf("PDS Fetch Error (User Annos): %v", err) 547 - annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 548 - } 549 - } else { 550 - annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 551 } 552 553 if err != nil { 554 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 581 viewerDID := h.getViewerDID(r) 582 583 if offset == 0 && viewerDID != "" && did == viewerDID { 584 - raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit) 585 - if err == nil { 586 - for _, r := range raw { 587 - if hi, ok := r.(*db.Highlight); ok { 588 - highlights = append(highlights, *hi) 589 - } 590 } 591 - go func() { 592 - for _, hi := range highlights { 593 - h.db.CreateHighlight(&hi) 594 - } 595 - }() 596 - } else { 597 - log.Printf("PDS Fetch Error (User Highs): %v", err) 598 - highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 599 - } 600 - } else { 601 - highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 602 } 603 604 if err != nil { 605 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 632 viewerDID := h.getViewerDID(r) 633 634 if offset == 0 && viewerDID != "" && did == viewerDID { 635 - raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit) 636 - if err == nil { 637 - for _, r := range raw { 638 - if b, ok := r.(*db.Bookmark); ok { 639 - bookmarks = append(bookmarks, *b) 640 - } 641 } 642 - go func() { 643 - for _, b := range bookmarks { 644 - h.db.CreateBookmark(&b) 645 - } 646 - }() 647 - } else { 648 - log.Printf("PDS Fetch Error (User Books): %v", err) 649 - bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 650 - } 651 - } else { 652 - bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 653 } 654 655 if err != nil { 656 http.Error(w, err.Error(), http.StatusInternalServerError)
··· 1 package api 2 3 import ( 4 + "context" 5 "encoding/json" 6 + "fmt" 7 "io" 8 "log" 9 "net/http" ··· 64 r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) 65 r.Delete("/collections/items", collectionService.RemoveCollectionItem) 66 r.Get("/collections/containing", collectionService.GetAnnotationCollections) 67 + r.Get("/collection", collectionService.GetCollection) 68 r.Post("/sync", h.SyncAll) 69 70 r.Get("/targets", h.GetByTarget) ··· 141 limit := parseIntParam(r, "limit", 50) 142 tag := r.URL.Query().Get("tag") 143 creator := r.URL.Query().Get("creator") 144 + feedType := r.URL.Query().Get("type") 145 146 viewerDID := h.getViewerDID(r) 147 148 + if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 149 if creator == viewerDID { 150 h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 151 return ··· 158 var collectionItems []db.CollectionItem 159 var err error 160 161 + motivation := r.URL.Query().Get("motivation") 162 + 163 if tag != "" { 164 if creator != "" { 165 + if motivation == "" || motivation == "commenting" { 166 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 167 + } 168 + if motivation == "" || motivation == "highlighting" { 169 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 170 + } 171 + if motivation == "" || motivation == "bookmarking" { 172 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 173 + } 174 collectionItems = []db.CollectionItem{} 175 } else { 176 + if motivation == "" || motivation == "commenting" { 177 + annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 178 + } 179 + if motivation == "" || motivation == "highlighting" { 180 + highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 181 + } 182 + if motivation == "" || motivation == "bookmarking" { 183 + bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 184 + } 185 collectionItems = []db.CollectionItem{} 186 } 187 } else if creator != "" { 188 + if motivation == "" || motivation == "commenting" { 189 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 190 + } 191 + if motivation == "" || motivation == "highlighting" { 192 + highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 193 + } 194 + if motivation == "" || motivation == "bookmarking" { 195 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 196 + } 197 collectionItems = []db.CollectionItem{} 198 } else { 199 + if motivation == "" || motivation == "commenting" { 200 + annotations, _ = h.db.GetRecentAnnotations(limit, 0) 201 + } 202 + if motivation == "" || motivation == "highlighting" { 203 + highlights, _ = h.db.GetRecentHighlights(limit, 0) 204 + } 205 + if motivation == "" || motivation == "bookmarking" { 206 + bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 207 + } 208 + if motivation == "" { 209 + collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 210 + if err != nil { 211 + log.Printf("Error fetching collection items: %v\n", err) 212 + } 213 } 214 } 215 ··· 217 authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 218 authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 219 220 + if len(collectionItems) > 0 { 221 + var sembleURIs []string 222 + for _, item := range collectionItems { 223 + if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 224 + sembleURIs = append(sembleURIs, item.AnnotationURI) 225 + } 226 + } 227 + if len(sembleURIs) > 0 { 228 + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 229 + defer cancel() 230 + ensureSembleCardsIndexed(ctx, h.db, sembleURIs) 231 + } 232 + } 233 + 234 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 235 236 var feed []interface{} ··· 247 feed = append(feed, ci) 248 } 249 250 + if feedType != "" && feedType != "all" && feedType != "my-feed" { 251 + var filtered []interface{} 252 + for _, item := range feed { 253 + isSemble := false 254 + var uri string 255 + switch v := item.(type) { 256 + case APIAnnotation: 257 + uri = v.ID 258 + case APIHighlight: 259 + uri = v.ID 260 + case APIBookmark: 261 + uri = v.ID 262 + case APICollectionItem: 263 + uri = v.ID 264 + } 265 + if strings.Contains(uri, "network.cosmik") { 266 + isSemble = true 267 + } 268 + 269 + if feedType == "semble" && isSemble { 270 + filtered = append(filtered, item) 271 + } else if feedType == "margin" && !isSemble { 272 + filtered = append(filtered, item) 273 + } else if feedType == "popular" { 274 + filtered = append(filtered, item) 275 + } 276 + } 277 + feed = filtered 278 + } 279 + 280 + if feedType == "popular" { 281 + sortFeedByPopularity(feed) 282 + } else { 283 + sortFeed(feed) 284 + } 285 286 if len(feed) > limit { 287 feed = feed[:limit] ··· 368 h.db.CreateBookmark(&b) 369 } 370 }() 371 + 372 + collectionItems := []db.CollectionItem{} 373 + if tag == "" { 374 + items, err := h.db.GetCollectionItemsByAuthor(did) 375 + if err != nil { 376 + log.Printf("Error fetching collection items for user feed: %v", err) 377 + } else { 378 + collectionItems = items 379 + } 380 + } 381 + 382 + if len(collectionItems) > 0 { 383 + var sembleURIs []string 384 + for _, item := range collectionItems { 385 + if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 386 + sembleURIs = append(sembleURIs, item.AnnotationURI) 387 + } 388 + } 389 + if len(sembleURIs) > 0 { 390 + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 391 + defer cancel() 392 + ensureSembleCardsIndexed(ctx, h.db, sembleURIs) 393 + } 394 + } 395 396 authAnnos, _ := hydrateAnnotations(h.db, annotations, did) 397 authHighs, _ := hydrateHighlights(h.db, highlights, did) 398 authBooks, _ := hydrateBookmarks(h.db, bookmarks, did) 399 + authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, did) 400 401 var feed []interface{} 402 for _, a := range authAnnos { ··· 407 } 408 for _, b := range authBooks { 409 feed = append(feed, b) 410 + } 411 + for _, ci := range authCollectionItems { 412 + feed = append(feed, ci) 413 } 414 415 sortFeed(feed) ··· 471 } 472 } 473 474 + func sortFeedByPopularity(feed []interface{}) { 475 + for i := 0; i < len(feed); i++ { 476 + for j := i + 1; j < len(feed); j++ { 477 + p1 := getPopularity(feed[i]) 478 + p2 := getPopularity(feed[j]) 479 + if p1 < p2 { 480 + feed[i], feed[j] = feed[j], feed[i] 481 + } 482 + } 483 + } 484 + } 485 + 486 + func getPopularity(item interface{}) int { 487 + switch v := item.(type) { 488 + case APIAnnotation: 489 + return v.LikeCount + v.ReplyCount 490 + case APIHighlight: 491 + return v.LikeCount + v.ReplyCount 492 + case APIBookmark: 493 + return v.LikeCount + v.ReplyCount 494 + case APICollectionItem: 495 + pop := 0 496 + if v.Annotation != nil { 497 + pop += v.Annotation.LikeCount + v.Annotation.ReplyCount 498 + } 499 + if v.Highlight != nil { 500 + pop += v.Highlight.LikeCount + v.Highlight.ReplyCount 501 + } 502 + if v.Bookmark != nil { 503 + pop += v.Bookmark.LikeCount + v.Bookmark.ReplyCount 504 + } 505 + return pop 506 + default: 507 + return 0 508 + } 509 + } 510 + 511 func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 512 uri := r.URL.Query().Get("uri") 513 if uri == "" { ··· 545 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 546 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 547 return 548 + } 549 + } 550 + } 551 + 552 + if strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark") { 553 + if strings.HasPrefix(uri, "at://") { 554 + uriWithoutScheme := strings.TrimPrefix(uri, "at://") 555 + parts := strings.Split(uriWithoutScheme, "/") 556 + if len(parts) >= 3 { 557 + did := parts[0] 558 + rkey := parts[len(parts)-1] 559 + 560 + sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", did, rkey) 561 + 562 + if annotation, err := h.db.GetAnnotationByURI(sembleURI); err == nil { 563 + if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 564 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 565 + return 566 + } 567 + } 568 + 569 + if bookmark, err := h.db.GetBookmarkByURI(sembleURI); err == nil { 570 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 571 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 572 + return 573 + } 574 + } 575 } 576 } 577 } ··· 702 viewerDID := h.getViewerDID(r) 703 704 if offset == 0 && viewerDID != "" && did == viewerDID { 705 + go func() { 706 + if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit); err != nil { 707 + log.Printf("Background sync error (annotations): %v", err) 708 } 709 + }() 710 } 711 + 712 + annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 713 714 if err != nil { 715 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 742 viewerDID := h.getViewerDID(r) 743 744 if offset == 0 && viewerDID != "" && did == viewerDID { 745 + go func() { 746 + if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit); err != nil { 747 + log.Printf("Background sync error (highlights): %v", err) 748 } 749 + }() 750 } 751 + 752 + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 753 754 if err != nil { 755 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 782 viewerDID := h.getViewerDID(r) 783 784 if offset == 0 && viewerDID != "" && did == viewerDID { 785 + go func() { 786 + if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit); err != nil { 787 + log.Printf("Background sync error (bookmarks): %v", err) 788 } 789 + }() 790 } 791 + 792 + bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 793 794 if err != nil { 795 http.Error(w, err.Error(), http.StatusInternalServerError)
+14
backend/internal/api/hydration.go
··· 549 highlightURIs = append(highlightURIs, item.AnnotationURI) 550 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 551 bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) 552 } 553 } 554 ··· 633 apiItem.Highlight = &val 634 } else if val, ok := bookmarksMap[item.AnnotationURI]; ok { 635 apiItem.Bookmark = &val 636 } 637 638 result[i] = apiItem
··· 549 highlightURIs = append(highlightURIs, item.AnnotationURI) 550 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 551 bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) 552 + } else if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 553 + annotationURIs = append(annotationURIs, item.AnnotationURI) 554 + bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) 555 } 556 } 557 ··· 636 apiItem.Highlight = &val 637 } else if val, ok := bookmarksMap[item.AnnotationURI]; ok { 638 apiItem.Bookmark = &val 639 + } else if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 640 + apiItem.Annotation = &APIAnnotation{ 641 + ID: item.AnnotationURI, 642 + Type: "Semble Card", 643 + Target: APITarget{ 644 + Source: "https://semble.so", 645 + Title: "Content Unavailable", 646 + }, 647 + CreatedAt: item.CreatedAt, 648 + Author: profiles[item.AuthorDID], 649 + } 650 } 651 652 result[i] = apiItem
+219
backend/internal/api/semble_fetch.go
···
··· 1 + package api 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "strings" 10 + "sync" 11 + "time" 12 + 13 + "margin.at/internal/db" 14 + "margin.at/internal/xrpc" 15 + ) 16 + 17 + func ensureSembleCardsIndexed(ctx context.Context, database *db.DB, uris []string) { 18 + if len(uris) == 0 || database == nil { 19 + return 20 + } 21 + 22 + uniq := make(map[string]struct{}, len(uris)) 23 + deduped := make([]string, 0, len(uris)) 24 + for _, u := range uris { 25 + if u == "" { 26 + continue 27 + } 28 + if _, ok := uniq[u]; ok { 29 + continue 30 + } 31 + uniq[u] = struct{}{} 32 + deduped = append(deduped, u) 33 + } 34 + if len(deduped) == 0 { 35 + return 36 + } 37 + 38 + existingAnnos, _ := database.GetAnnotationsByURIs(deduped) 39 + existingBooks, _ := database.GetBookmarksByURIs(deduped) 40 + 41 + foundSet := make(map[string]bool, len(existingAnnos)+len(existingBooks)) 42 + for _, a := range existingAnnos { 43 + foundSet[a.URI] = true 44 + } 45 + for _, b := range existingBooks { 46 + foundSet[b.URI] = true 47 + } 48 + 49 + missing := make([]string, 0) 50 + for _, u := range deduped { 51 + if !foundSet[u] { 52 + missing = append(missing, u) 53 + } 54 + } 55 + if len(missing) == 0 { 56 + return 57 + } 58 + 59 + log.Printf("Active Cache: Fetching %d missing Semble cards...", len(missing)) 60 + fetchAndIndexSembleCards(ctx, database, missing) 61 + } 62 + 63 + func fetchAndIndexSembleCards(ctx context.Context, database *db.DB, uris []string) { 64 + sem := make(chan struct{}, 5) 65 + var wg sync.WaitGroup 66 + 67 + for _, uri := range uris { 68 + select { 69 + case <-ctx.Done(): 70 + return 71 + default: 72 + } 73 + 74 + wg.Add(1) 75 + go func(u string) { 76 + defer wg.Done() 77 + 78 + select { 79 + case sem <- struct{}{}: 80 + defer func() { <-sem }() 81 + case <-ctx.Done(): 82 + return 83 + } 84 + 85 + if err := fetchSembleCard(ctx, database, u); err != nil { 86 + if ctx.Err() == nil { 87 + log.Printf("Failed to lazy fetch card %s: %v", u, err) 88 + } 89 + } 90 + }(uri) 91 + } 92 + 93 + done := make(chan struct{}) 94 + go func() { 95 + wg.Wait() 96 + close(done) 97 + }() 98 + 99 + select { 100 + case <-done: 101 + case <-ctx.Done(): 102 + return 103 + } 104 + } 105 + 106 + func fetchSembleCard(ctx context.Context, database *db.DB, uri string) error { 107 + if database == nil { 108 + return fmt.Errorf("nil database") 109 + } 110 + 111 + if !strings.HasPrefix(uri, "at://") { 112 + return fmt.Errorf("invalid uri") 113 + } 114 + uriWithoutScheme := strings.TrimPrefix(uri, "at://") 115 + parts := strings.Split(uriWithoutScheme, "/") 116 + if len(parts) < 3 { 117 + return fmt.Errorf("invalid uri parts: expected at least 3 parts") 118 + } 119 + did, collection, rkey := parts[0], parts[1], parts[2] 120 + 121 + pds, err := xrpc.ResolveDIDToPDS(did) 122 + if err != nil { 123 + return fmt.Errorf("failed to resolve PDS: %w", err) 124 + } 125 + 126 + client := &http.Client{Timeout: 10 * time.Second} 127 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", pds, did, collection, rkey) 128 + 129 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 130 + if err != nil { 131 + return err 132 + } 133 + 134 + resp, err := client.Do(req) 135 + if err != nil { 136 + return fmt.Errorf("failed to fetch record: %w", err) 137 + } 138 + defer resp.Body.Close() 139 + 140 + if resp.StatusCode != 200 { 141 + return fmt.Errorf("unexpected status %d", resp.StatusCode) 142 + } 143 + 144 + var output xrpc.GetRecordOutput 145 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 146 + return err 147 + } 148 + 149 + var card xrpc.SembleCard 150 + if err := json.Unmarshal(output.Value, &card); err != nil { 151 + return err 152 + } 153 + 154 + createdAt := card.GetCreatedAtTime() 155 + content, err := card.ParseContent() 156 + if err != nil { 157 + return err 158 + } 159 + 160 + switch card.Type { 161 + case "NOTE": 162 + note, ok := content.(*xrpc.SembleNoteContent) 163 + if !ok { 164 + return fmt.Errorf("invalid note content") 165 + } 166 + 167 + targetSource := card.URL 168 + if targetSource == "" { 169 + return fmt.Errorf("missing target source") 170 + } 171 + 172 + targetHash := db.HashURL(targetSource) 173 + motivation := "commenting" 174 + bodyValue := note.Text 175 + 176 + annotation := &db.Annotation{ 177 + URI: uri, 178 + AuthorDID: did, 179 + Motivation: motivation, 180 + BodyValue: &bodyValue, 181 + TargetSource: targetSource, 182 + TargetHash: targetHash, 183 + CreatedAt: createdAt, 184 + IndexedAt: time.Now(), 185 + } 186 + return database.CreateAnnotation(annotation) 187 + 188 + case "URL": 189 + urlContent, ok := content.(*xrpc.SembleURLContent) 190 + if !ok { 191 + return fmt.Errorf("invalid url content") 192 + } 193 + 194 + source := urlContent.URL 195 + if source == "" { 196 + return fmt.Errorf("missing source") 197 + } 198 + sourceHash := db.HashURL(source) 199 + 200 + var titlePtr *string 201 + if urlContent.Metadata != nil && urlContent.Metadata.Title != "" { 202 + t := urlContent.Metadata.Title 203 + titlePtr = &t 204 + } 205 + 206 + bookmark := &db.Bookmark{ 207 + URI: uri, 208 + AuthorDID: did, 209 + Source: source, 210 + SourceHash: sourceHash, 211 + Title: titlePtr, 212 + CreatedAt: createdAt, 213 + IndexedAt: time.Now(), 214 + } 215 + return database.CreateBookmark(bookmark) 216 + } 217 + 218 + return nil 219 + }
+171 -8
backend/internal/firehose/ingester.go
··· 16 ) 17 18 const ( 19 - CollectionAnnotation = "at.margin.annotation" 20 - CollectionHighlight = "at.margin.highlight" 21 - CollectionBookmark = "at.margin.bookmark" 22 - CollectionReply = "at.margin.reply" 23 - CollectionLike = "at.margin.like" 24 - CollectionCollection = "at.margin.collection" 25 - CollectionCollectionItem = "at.margin.collectionItem" 26 - CollectionProfile = "at.margin.profile" 27 ) 28 29 var RelayURL = "wss://jetstream2.us-east.bsky.network/subscribe" ··· 52 i.RegisterHandler(CollectionCollection, i.handleCollection) 53 i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem) 54 i.RegisterHandler(CollectionProfile, i.handleProfile) 55 56 return i 57 } ··· 235 i.db.RemoveFromCollection(uri) 236 case CollectionProfile: 237 i.db.DeleteProfile(uri) 238 } 239 } 240 ··· 687 log.Printf("Indexed profile from %s", event.Repo) 688 } 689 }
··· 16 ) 17 18 const ( 19 + CollectionAnnotation = "at.margin.annotation" 20 + CollectionHighlight = "at.margin.highlight" 21 + CollectionBookmark = "at.margin.bookmark" 22 + CollectionReply = "at.margin.reply" 23 + CollectionLike = "at.margin.like" 24 + CollectionCollection = "at.margin.collection" 25 + CollectionCollectionItem = "at.margin.collectionItem" 26 + CollectionProfile = "at.margin.profile" 27 + CollectionSembleCard = "network.cosmik.card" 28 + CollectionSembleCollection = "network.cosmik.collection" 29 ) 30 31 var RelayURL = "wss://jetstream2.us-east.bsky.network/subscribe" ··· 54 i.RegisterHandler(CollectionCollection, i.handleCollection) 55 i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem) 56 i.RegisterHandler(CollectionProfile, i.handleProfile) 57 + i.RegisterHandler(CollectionSembleCard, i.handleSembleCard) 58 + i.RegisterHandler(CollectionSembleCollection, i.handleSembleCollection) 59 + i.RegisterHandler(xrpc.CollectionSembleCollectionLink, i.handleSembleCollectionLink) 60 61 return i 62 } ··· 240 i.db.RemoveFromCollection(uri) 241 case CollectionProfile: 242 i.db.DeleteProfile(uri) 243 + case CollectionSembleCard: 244 + i.db.DeleteAnnotation(uri) 245 + i.db.DeleteBookmark(uri) 246 + case CollectionSembleCollection: 247 + i.db.DeleteCollection(uri) 248 + case xrpc.CollectionSembleCollectionLink: 249 + i.db.RemoveFromCollection(uri) 250 + 251 } 252 } 253 ··· 700 log.Printf("Indexed profile from %s", event.Repo) 701 } 702 } 703 + 704 + func (i *Ingester) handleSembleCard(event *FirehoseEvent) { 705 + var card xrpc.SembleCard 706 + if err := json.Unmarshal(event.Record, &card); err != nil { 707 + return 708 + } 709 + 710 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 711 + createdAt := card.GetCreatedAtTime() 712 + 713 + content, err := card.ParseContent() 714 + if err != nil { 715 + return 716 + } 717 + 718 + switch card.Type { 719 + case "NOTE": 720 + note, ok := content.(*xrpc.SembleNoteContent) 721 + if !ok { 722 + return 723 + } 724 + 725 + targetSource := card.URL 726 + if targetSource == "" { 727 + return 728 + } 729 + 730 + targetHash := db.HashURL(targetSource) 731 + motivation := "commenting" 732 + bodyValue := note.Text 733 + 734 + annotation := &db.Annotation{ 735 + URI: uri, 736 + AuthorDID: event.Repo, 737 + Motivation: motivation, 738 + BodyValue: &bodyValue, 739 + TargetSource: targetSource, 740 + TargetHash: targetHash, 741 + CreatedAt: createdAt, 742 + IndexedAt: time.Now(), 743 + } 744 + if err := i.db.CreateAnnotation(annotation); err != nil { 745 + log.Printf("Failed to index Semble NOTE as annotation: %v", err) 746 + } else { 747 + if card.ParentCard != nil { 748 + log.Printf("Indexed Semble NOTE from %s on %s (Parent: %s)", event.Repo, targetSource, card.ParentCard.URI) 749 + } else { 750 + log.Printf("Indexed Semble NOTE from %s on %s", event.Repo, targetSource) 751 + } 752 + } 753 + 754 + case "URL": 755 + urlContent, ok := content.(*xrpc.SembleURLContent) 756 + if !ok { 757 + return 758 + } 759 + 760 + source := urlContent.URL 761 + if source == "" { 762 + return 763 + } 764 + sourceHash := db.HashURL(source) 765 + 766 + var titlePtr *string 767 + if urlContent.Metadata != nil && urlContent.Metadata.Title != "" { 768 + t := urlContent.Metadata.Title 769 + titlePtr = &t 770 + } 771 + 772 + bookmark := &db.Bookmark{ 773 + URI: uri, 774 + AuthorDID: event.Repo, 775 + Source: source, 776 + SourceHash: sourceHash, 777 + Title: titlePtr, 778 + CreatedAt: createdAt, 779 + IndexedAt: time.Now(), 780 + } 781 + if err := i.db.CreateBookmark(bookmark); err != nil { 782 + log.Printf("Failed to index Semble URL as bookmark: %v", err) 783 + } else { 784 + log.Printf("Indexed Semble URL from %s: %s", event.Repo, source) 785 + } 786 + } 787 + } 788 + 789 + func (i *Ingester) handleSembleCollection(event *FirehoseEvent) { 790 + var record xrpc.SembleCollection 791 + if err := json.Unmarshal(event.Record, &record); err != nil { 792 + return 793 + } 794 + 795 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 796 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 797 + if err != nil { 798 + createdAt = time.Now() 799 + } 800 + 801 + var descPtr, iconPtr *string 802 + if record.Description != "" { 803 + descPtr = &record.Description 804 + } 805 + icon := "icon:semble" 806 + iconPtr = &icon 807 + 808 + collection := &db.Collection{ 809 + URI: uri, 810 + AuthorDID: event.Repo, 811 + Name: record.Name, 812 + Description: descPtr, 813 + Icon: iconPtr, 814 + CreatedAt: createdAt, 815 + IndexedAt: time.Now(), 816 + } 817 + 818 + if err := i.db.CreateCollection(collection); err != nil { 819 + log.Printf("Failed to index Semble collection: %v", err) 820 + } else { 821 + log.Printf("Indexed Semble collection from %s: %s", event.Repo, record.Name) 822 + } 823 + } 824 + 825 + func (i *Ingester) handleSembleCollectionLink(event *FirehoseEvent) { 826 + var record xrpc.SembleCollectionLink 827 + if err := json.Unmarshal(event.Record, &record); err != nil { 828 + return 829 + } 830 + 831 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 832 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 833 + if err != nil { 834 + createdAt = time.Now() 835 + } 836 + 837 + item := &db.CollectionItem{ 838 + URI: uri, 839 + AuthorDID: event.Repo, 840 + CollectionURI: record.Collection.URI, 841 + AnnotationURI: record.Card.URI, 842 + Position: 0, 843 + CreatedAt: createdAt, 844 + IndexedAt: time.Now(), 845 + } 846 + 847 + if err := i.db.AddToCollection(item); err != nil { 848 + log.Printf("Failed to index Semble collection link: %v", err) 849 + } else { 850 + log.Printf("Indexed Semble collection link from %s", event.Repo) 851 + } 852 + }
+179
backend/internal/sync/service.go
··· 6 "fmt" 7 "io" 8 "net/http" 9 "time" 10 11 "margin.at/internal/db" ··· 29 xrpc.CollectionLike, 30 xrpc.CollectionCollection, 31 xrpc.CollectionCollectionItem, 32 } 33 34 results := make(map[string]string) ··· 101 switch collectionNSID { 102 case xrpc.CollectionAnnotation: 103 localURIs, err = s.db.GetAnnotationURIs(did) 104 case xrpc.CollectionHighlight: 105 localURIs, err = s.db.GetHighlightURIs(did) 106 case xrpc.CollectionBookmark: 107 localURIs, err = s.db.GetBookmarkURIs(did) 108 case xrpc.CollectionCollection: 109 cols, e := s.db.GetCollectionsByAuthor(did) 110 if e == nil { 111 for _, c := range cols { 112 localURIs = append(localURIs, c.URI) 113 } 114 } else { 115 err = e 116 } ··· 120 for _, item := range items { 121 localURIs = append(localURIs, item.URI) 122 } 123 } else { 124 err = e 125 } ··· 129 for _, r := range replies { 130 localURIs = append(localURIs, r.URI) 131 } 132 } else { 133 err = e 134 } ··· 138 for _, l := range likes { 139 localURIs = append(localURIs, l.URI) 140 } 141 } else { 142 err = e 143 } ··· 161 _ = s.db.DeleteReply(uri) 162 case xrpc.CollectionLike: 163 _ = s.db.DeleteLike(uri) 164 } 165 deletedCount++ 166 } ··· 173 } 174 } 175 return results, nil 176 } 177 178 func strPtr(s string) *string { ··· 422 SubjectURI: record.Subject.URI, 423 CreatedAt: createdAt, 424 IndexedAt: time.Now(), 425 }) 426 } 427 return nil
··· 6 "fmt" 7 "io" 8 "net/http" 9 + "strings" 10 "time" 11 12 "margin.at/internal/db" ··· 30 xrpc.CollectionLike, 31 xrpc.CollectionCollection, 32 xrpc.CollectionCollectionItem, 33 + xrpc.CollectionSembleCard, 34 + xrpc.CollectionSembleCollection, 35 + xrpc.CollectionSembleCollectionLink, 36 } 37 38 results := make(map[string]string) ··· 105 switch collectionNSID { 106 case xrpc.CollectionAnnotation: 107 localURIs, err = s.db.GetAnnotationURIs(did) 108 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionAnnotation) 109 case xrpc.CollectionHighlight: 110 localURIs, err = s.db.GetHighlightURIs(did) 111 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionHighlight) 112 case xrpc.CollectionBookmark: 113 localURIs, err = s.db.GetBookmarkURIs(did) 114 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionBookmark) 115 case xrpc.CollectionCollection: 116 cols, e := s.db.GetCollectionsByAuthor(did) 117 if e == nil { 118 for _, c := range cols { 119 localURIs = append(localURIs, c.URI) 120 } 121 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionCollection) 122 } else { 123 err = e 124 } ··· 128 for _, item := range items { 129 localURIs = append(localURIs, item.URI) 130 } 131 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionCollectionItem) 132 } else { 133 err = e 134 } ··· 138 for _, r := range replies { 139 localURIs = append(localURIs, r.URI) 140 } 141 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionReply) 142 } else { 143 err = e 144 } ··· 148 for _, l := range likes { 149 localURIs = append(localURIs, l.URI) 150 } 151 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionLike) 152 + } else { 153 + err = e 154 + } 155 + case xrpc.CollectionSembleCard: 156 + annos, e1 := s.db.GetAnnotationURIs(did) 157 + books, e2 := s.db.GetBookmarkURIs(did) 158 + if e1 != nil { 159 + err = e1 160 + break 161 + } 162 + if e2 != nil { 163 + err = e2 164 + break 165 + } 166 + localURIs = append(localURIs, annos...) 167 + localURIs = append(localURIs, books...) 168 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionSembleCard) 169 + case xrpc.CollectionSembleCollection: 170 + cols, e := s.db.GetCollectionsByAuthor(did) 171 + if e == nil { 172 + for _, c := range cols { 173 + localURIs = append(localURIs, c.URI) 174 + } 175 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionSembleCollection) 176 + } else { 177 + err = e 178 + } 179 + case xrpc.CollectionSembleCollectionLink: 180 + items, e := s.db.GetCollectionItemsByAuthor(did) 181 + if e == nil { 182 + for _, item := range items { 183 + localURIs = append(localURIs, item.URI) 184 + } 185 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionSembleCollectionLink) 186 } else { 187 err = e 188 } ··· 206 _ = s.db.DeleteReply(uri) 207 case xrpc.CollectionLike: 208 _ = s.db.DeleteLike(uri) 209 + case xrpc.CollectionSembleCard: 210 + _ = s.db.DeleteAnnotation(uri) 211 + _ = s.db.DeleteBookmark(uri) 212 + case xrpc.CollectionSembleCollection: 213 + _ = s.db.DeleteCollection(uri) 214 + case xrpc.CollectionSembleCollectionLink: 215 + _ = s.db.RemoveFromCollection(uri) 216 } 217 deletedCount++ 218 } ··· 225 } 226 } 227 return results, nil 228 + } 229 + 230 + func filterURIsByCollection(uris []string, collectionNSID string) []string { 231 + if len(uris) == 0 || collectionNSID == "" { 232 + return uris 233 + } 234 + needle := "/" + collectionNSID + "/" 235 + out := make([]string, 0, len(uris)) 236 + for _, u := range uris { 237 + if strings.Contains(u, needle) { 238 + out = append(out, u) 239 + } 240 + } 241 + return out 242 } 243 244 func strPtr(s string) *string { ··· 488 SubjectURI: record.Subject.URI, 489 CreatedAt: createdAt, 490 IndexedAt: time.Now(), 491 + }) 492 + 493 + case xrpc.CollectionSembleCard: 494 + var card xrpc.SembleCard 495 + if err := json.Unmarshal(value, &card); err != nil { 496 + return err 497 + } 498 + 499 + createdAt := card.GetCreatedAtTime() 500 + 501 + content, err := card.ParseContent() 502 + if err != nil { 503 + return nil 504 + } 505 + 506 + switch card.Type { 507 + case "NOTE": 508 + note, ok := content.(*xrpc.SembleNoteContent) 509 + if !ok { 510 + return nil 511 + } 512 + 513 + targetSource := card.URL 514 + if targetSource == "" { 515 + return nil 516 + } 517 + 518 + targetHash := db.HashURL(targetSource) 519 + motivation := "commenting" 520 + bodyValue := note.Text 521 + 522 + return s.db.CreateAnnotation(&db.Annotation{ 523 + URI: uri, 524 + AuthorDID: did, 525 + Motivation: motivation, 526 + BodyValue: &bodyValue, 527 + TargetSource: targetSource, 528 + TargetHash: targetHash, 529 + CreatedAt: createdAt, 530 + IndexedAt: time.Now(), 531 + CID: cidPtr, 532 + }) 533 + 534 + case "URL": 535 + urlContent, ok := content.(*xrpc.SembleURLContent) 536 + if !ok { 537 + return nil 538 + } 539 + 540 + source := urlContent.URL 541 + if source == "" { 542 + return nil 543 + } 544 + sourceHash := db.HashURL(source) 545 + 546 + var titlePtr *string 547 + if urlContent.Metadata != nil && urlContent.Metadata.Title != "" { 548 + t := urlContent.Metadata.Title 549 + titlePtr = &t 550 + } 551 + 552 + return s.db.CreateBookmark(&db.Bookmark{ 553 + URI: uri, 554 + AuthorDID: did, 555 + Source: source, 556 + SourceHash: sourceHash, 557 + Title: titlePtr, 558 + CreatedAt: createdAt, 559 + IndexedAt: time.Now(), 560 + CID: cidPtr, 561 + }) 562 + } 563 + 564 + case xrpc.CollectionSembleCollection: 565 + var record xrpc.SembleCollection 566 + if err := json.Unmarshal(value, &record); err != nil { 567 + return err 568 + } 569 + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) 570 + 571 + var descPtr, iconPtr *string 572 + if record.Description != "" { 573 + d := record.Description 574 + descPtr = &d 575 + } 576 + icon := "icon:semble" 577 + iconPtr = &icon 578 + 579 + return s.db.CreateCollection(&db.Collection{ 580 + URI: uri, 581 + AuthorDID: did, 582 + Name: record.Name, 583 + Description: descPtr, 584 + Icon: iconPtr, 585 + CreatedAt: createdAt, 586 + IndexedAt: time.Now(), 587 + }) 588 + 589 + case xrpc.CollectionSembleCollectionLink: 590 + var record xrpc.SembleCollectionLink 591 + if err := json.Unmarshal(value, &record); err != nil { 592 + return err 593 + } 594 + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) 595 + 596 + return s.db.AddToCollection(&db.CollectionItem{ 597 + URI: uri, 598 + AuthorDID: did, 599 + CollectionURI: record.Collection.URI, 600 + AnnotationURI: record.Card.URI, 601 + Position: 0, 602 + CreatedAt: createdAt, 603 + IndexedAt: time.Now(), 604 }) 605 } 606 return nil
+82
backend/internal/xrpc/semble.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + const ( 9 + CollectionSembleCard = "network.cosmik.card" 10 + CollectionSembleCollection = "network.cosmik.collection" 11 + CollectionSembleCollectionLink = "network.cosmik.collectionLink" 12 + ) 13 + 14 + type SembleCard struct { 15 + Type string `json:"type"` 16 + Content json.RawMessage `json:"content"` 17 + URL string `json:"url,omitempty"` 18 + ParentCard *StrongRef `json:"parentCard,omitempty"` 19 + CreatedAt string `json:"createdAt"` 20 + } 21 + 22 + type SembleURLContent struct { 23 + URL string `json:"url"` 24 + Metadata *SembleURLMetadata `json:"metadata,omitempty"` 25 + } 26 + 27 + type SembleNoteContent struct { 28 + Text string `json:"text"` 29 + } 30 + 31 + type SembleURLMetadata struct { 32 + Title string `json:"title,omitempty"` 33 + Description string `json:"description,omitempty"` 34 + Author string `json:"author,omitempty"` 35 + SiteName string `json:"siteName,omitempty"` 36 + } 37 + 38 + type SembleCollection struct { 39 + Name string `json:"name"` 40 + Description string `json:"description,omitempty"` 41 + AccessType string `json:"accessType"` 42 + CreatedAt string `json:"createdAt"` 43 + } 44 + 45 + type SembleCollectionLink struct { 46 + Collection StrongRef `json:"collection"` 47 + Card StrongRef `json:"card"` 48 + AddedBy string `json:"addedBy"` 49 + AddedAt string `json:"addedAt"` 50 + CreatedAt string `json:"createdAt"` 51 + } 52 + 53 + type StrongRef struct { 54 + URI string `json:"uri"` 55 + CID string `json:"cid"` 56 + } 57 + 58 + func (c *SembleCard) ParseContent() (interface{}, error) { 59 + switch c.Type { 60 + case "URL": 61 + var content SembleURLContent 62 + if err := json.Unmarshal(c.Content, &content); err != nil { 63 + return nil, err 64 + } 65 + return &content, nil 66 + case "NOTE": 67 + var content SembleNoteContent 68 + if err := json.Unmarshal(c.Content, &content); err != nil { 69 + return nil, err 70 + } 71 + return &content, nil 72 + } 73 + return nil, nil 74 + } 75 + 76 + func (c *SembleCard) GetCreatedAtTime() time.Time { 77 + t, err := time.Parse(time.RFC3339, c.CreatedAt) 78 + if err != nil { 79 + return time.Now() 80 + } 81 + return t 82 + }
+1
web/public/semble-logo.svg
···
··· 1 + <svg width="24" height="24" viewBox="0 0 32 43" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M31.0164 33.1306C31.0164 38.581 25.7882 42.9994 15.8607 42.9994C5.93311 42.9994 0 37.5236 0 32.0732C0 26.6228 5.93311 23.2617 15.8607 23.2617C25.7882 23.2617 31.0164 27.6802 31.0164 33.1306Z" fill="#ff6400"></path><path d="M25.7295 19.3862C25.7295 22.5007 20.7964 22.2058 15.1558 22.2058C9.51511 22.2058 4.93445 22.1482 4.93445 19.0337C4.93445 15.9192 9.71537 12.6895 15.356 12.6895C20.9967 12.6895 25.7295 16.2717 25.7295 19.3862Z" fill="#ff6400"></path><path d="M25.0246 10.9256C25.0246 14.0401 20.7964 11.9829 15.1557 11.9829C9.51506 11.9829 6.34424 13.6876 6.34424 10.5731C6.34424 7.45857 9.51506 5.63867 15.1557 5.63867C20.7964 5.63867 25.0246 7.81103 25.0246 10.9256Z" fill="#ff6400"></path><path d="M20.4426 3.5755C20.4426 5.8323 18.2088 4.22951 15.2288 4.22951C12.2489 4.22951 10.5737 5.8323 10.5737 3.5755C10.5737 1.31871 12.2489 0 15.2288 0C18.2088 0 20.4426 1.31871 20.4426 3.5755Z" fill="#ff6400"></path></svg>
+8
web/src/api/client.js
··· 28 offset = 0, 29 tag = "", 30 creator = "", 31 ) { 32 let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; 33 if (tag) url += `&tag=${encodeURIComponent(tag)}`; 34 if (creator) url += `&creator=${encodeURIComponent(creator)}`; 35 return request(url); 36 } 37 ··· 135 let url = `${API_BASE}/collections`; 136 if (did) url += `?author=${encodeURIComponent(did)}`; 137 return request(url); 138 } 139 140 export async function getCollectionsContaining(annotationUri) {
··· 28 offset = 0, 29 tag = "", 30 creator = "", 31 + feedType = "", 32 + motivation = "", 33 ) { 34 let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; 35 if (tag) url += `&tag=${encodeURIComponent(tag)}`; 36 if (creator) url += `&creator=${encodeURIComponent(creator)}`; 37 + if (feedType) url += `&type=${encodeURIComponent(feedType)}`; 38 + if (motivation) url += `&motivation=${encodeURIComponent(motivation)}`; 39 return request(url); 40 } 41 ··· 139 let url = `${API_BASE}/collections`; 140 if (did) url += `?author=${encodeURIComponent(did)}`; 141 return request(url); 142 + } 143 + 144 + export async function getCollection(uri) { 145 + return request(`${API_BASE}/collection?uri=${encodeURIComponent(uri)}`); 146 } 147 148 export async function getCollectionsContaining(annotationUri) {
+44 -3
web/src/components/AnnotationCard.jsx
··· 224 <UserMeta author={data.author} createdAt={data.createdAt} /> 225 </div> 226 <div className="annotation-header-right"> 227 - <div style={{ display: "flex", gap: "4px" }}> 228 {hasEditHistory && !data.color && !data.description && ( 229 <button 230 className="annotation-action action-icon-only" ··· 235 </button> 236 )} 237 238 - {isOwner && ( 239 <> 240 {!data.color && !data.description && ( 241 <button ··· 407 text={data.title || data.url} 408 handle={data.author?.handle} 409 type="Annotation" 410 /> 411 <button 412 className="annotation-action" ··· 557 </div> 558 559 <div className="annotation-header-right"> 560 - <div style={{ display: "flex", gap: "4px" }}> 561 {isOwner && ( 562 <> 563 <button
··· 224 <UserMeta author={data.author} createdAt={data.createdAt} /> 225 </div> 226 <div className="annotation-header-right"> 227 + <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 228 + {data.uri && data.uri.includes("network.cosmik") && ( 229 + <div 230 + style={{ 231 + display: "flex", 232 + alignItems: "center", 233 + gap: "4px", 234 + fontSize: "0.75rem", 235 + color: "var(--text-tertiary)", 236 + marginRight: "8px", 237 + }} 238 + title="Added using Semble" 239 + > 240 + <span>via Semble</span> 241 + <img 242 + src="/semble-logo.svg" 243 + alt="Semble" 244 + style={{ width: "16px", height: "16px" }} 245 + /> 246 + </div> 247 + )} 248 {hasEditHistory && !data.color && !data.description && ( 249 <button 250 className="annotation-action action-icon-only" ··· 255 </button> 256 )} 257 258 + {isOwner && !(data.uri && data.uri.includes("network.cosmik")) && ( 259 <> 260 {!data.color && !data.description && ( 261 <button ··· 427 text={data.title || data.url} 428 handle={data.author?.handle} 429 type="Annotation" 430 + url={data.url} 431 /> 432 <button 433 className="annotation-action" ··· 578 </div> 579 580 <div className="annotation-header-right"> 581 + <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 582 + {data.uri && data.uri.includes("network.cosmik") && ( 583 + <div 584 + style={{ 585 + display: "flex", 586 + alignItems: "center", 587 + gap: "4px", 588 + fontSize: "0.75rem", 589 + color: "var(--text-tertiary)", 590 + marginRight: "8px", 591 + }} 592 + title="Added using Semble" 593 + > 594 + <span>via Semble</span> 595 + <img 596 + src="/semble-logo.svg" 597 + alt="Semble" 598 + style={{ width: "16px", height: "16px" }} 599 + /> 600 + </div> 601 + )} 602 {isOwner && ( 603 <> 604 <button
+34 -9
web/src/components/BookmarkCard.jsx
··· 105 </div> 106 107 <div className="annotation-header-right"> 108 - <div style={{ display: "flex", gap: "4px" }}> 109 - {(isOwner || onDelete) && ( 110 - <button 111 - className="annotation-action action-icon-only" 112 - onClick={handleDelete} 113 - disabled={deleting} 114 - title="Delete" 115 > 116 - <TrashIcon size={16} /> 117 - </button> 118 )} 119 </div> 120 </div> 121 </header> ··· 164 text={data.title || data.description} 165 handle={data.author?.handle} 166 type="Bookmark" 167 /> 168 <button 169 className="annotation-action"
··· 105 </div> 106 107 <div className="annotation-header-right"> 108 + <div style={{ display: "flex", gap: "4px", alignItems: "center" }}> 109 + {data.uri && data.uri.includes("network.cosmik") && ( 110 + <div 111 + style={{ 112 + display: "flex", 113 + alignItems: "center", 114 + gap: "4px", 115 + fontSize: "0.75rem", 116 + color: "var(--text-tertiary)", 117 + marginRight: "8px", 118 + }} 119 + title="Added using Semble" 120 > 121 + <span>via Semble</span> 122 + <img 123 + src="/semble-logo.svg" 124 + alt="Semble" 125 + style={{ width: "16px", height: "16px" }} 126 + /> 127 + </div> 128 )} 129 + <div style={{ display: "flex", gap: "4px" }}> 130 + {((isOwner && 131 + !(data.uri && data.uri.includes("network.cosmik"))) || 132 + onDelete) && ( 133 + <button 134 + className="annotation-action action-icon-only" 135 + onClick={handleDelete} 136 + disabled={deleting} 137 + title="Delete" 138 + > 139 + <TrashIcon size={16} /> 140 + </button> 141 + )} 142 + </div> 143 </div> 144 </div> 145 </header> ··· 188 text={data.title || data.description} 189 handle={data.author?.handle} 190 type="Bookmark" 191 + url={data.url} 192 /> 193 <button 194 className="annotation-action"
+11
web/src/components/CollectionIcon.jsx
··· 89 return <Folder size={size} className={className} />; 90 } 91 92 if (icon.startsWith("icon:")) { 93 const iconName = icon.replace("icon:", ""); 94 const IconComponent = ICON_MAP[iconName];
··· 89 return <Folder size={size} className={className} />; 90 } 91 92 + if (icon === "icon:semble") { 93 + return ( 94 + <img 95 + src="/semble-logo.svg" 96 + alt="Semble" 97 + style={{ width: size, height: size, objectFit: "contain" }} 98 + className={className} 99 + /> 100 + ); 101 + } 102 + 103 if (icon.startsWith("icon:")) { 104 const iconName = icon.replace("icon:", ""); 105 const IconComponent = ICON_MAP[iconName];
+1 -1
web/src/components/CollectionRow.jsx
··· 24 </div> 25 <ChevronRight size={20} className="collection-row-arrow" /> 26 </Link> 27 - {onEdit && ( 28 <button 29 onClick={(e) => { 30 e.preventDefault();
··· 24 </div> 25 <ChevronRight size={20} className="collection-row-arrow" /> 26 </Link> 27 + {onEdit && !collection.uri.includes("network.cosmik") && ( 28 <button 29 onClick={(e) => { 30 e.preventDefault();
+109 -31
web/src/components/ShareMenu.jsx
··· 97 { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 ]; 99 100 - export default function ShareMenu({ uri, text, customUrl, handle, type }) { 101 const [isOpen, setIsOpen] = useState(false); 102 const [copied, setCopied] = useState(false); 103 const [copiedAturi, setCopiedAturi] = useState(false); ··· 109 110 const uriParts = uri.split("/"); 111 const rkey = uriParts[uriParts.length - 1]; 112 113 if (handle && type) { 114 return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 115 } 116 117 - const did = uriParts[2]; 118 return `${window.location.origin}/at/${did}/${rkey}`; 119 }; 120 ··· 195 setIsOpen(false); 196 }; 197 198 return ( 199 <div className="share-menu-container" ref={menuRef}> 200 <button ··· 222 223 {isOpen && ( 224 <div className="share-menu"> 225 - <div className="share-menu-section"> 226 - <div className="share-menu-label">Share to</div> 227 - {BLUESKY_FORKS.map((fork) => ( 228 <button 229 - key={fork.domain} 230 className="share-menu-item" 231 - onClick={() => handleShareToFork(fork.domain)} 232 > 233 - <span className="share-menu-icon"> 234 - <fork.Icon /> 235 - </span> 236 - <span>{fork.name}</span> 237 </button> 238 - ))} 239 - </div> 240 - <div className="share-menu-divider" /> 241 - <button className="share-menu-item" onClick={handleCopy}> 242 - {copied ? <Check size={16} /> : <Copy size={16} />} 243 - <span>{copied ? "Copied!" : "Copy Link"}</span> 244 - </button> 245 - <button 246 - className="share-menu-item" 247 - onClick={handleCopyAturi} 248 - title="Copy a universal link atproto link (via aturi.to)" 249 - > 250 - {copiedAturi ? <Check size={16} /> : <AturiIcon size={16} />} 251 - <span>{copiedAturi ? "Copied!" : "Copy Universal Link"}</span> 252 - </button> 253 - {navigator.share && ( 254 - <button className="share-menu-item" onClick={handleSystemShare}> 255 - <ExternalLink size={16} /> 256 - <span>More...</span> 257 - </button> 258 )} 259 </div> 260 )}
··· 97 { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 ]; 99 100 + export default function ShareMenu({ uri, text, customUrl, handle, type, url }) { 101 const [isOpen, setIsOpen] = useState(false); 102 const [copied, setCopied] = useState(false); 103 const [copiedAturi, setCopiedAturi] = useState(false); ··· 109 110 const uriParts = uri.split("/"); 111 const rkey = uriParts[uriParts.length - 1]; 112 + const did = uriParts[2]; 113 + 114 + if (uri.includes("network.cosmik.card")) { 115 + return `${window.location.origin}/at/${did}/${rkey}`; 116 + } 117 118 if (handle && type) { 119 return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 120 } 121 122 return `${window.location.origin}/at/${did}/${rkey}`; 123 }; 124 ··· 199 setIsOpen(false); 200 }; 201 202 + const isSemble = uri && uri.includes("network.cosmik"); 203 + const sembleUrl = (() => { 204 + if (!isSemble) return ""; 205 + const parts = uri.split("/"); 206 + const rkey = parts[parts.length - 1]; 207 + const userHandle = handle || (parts.length > 2 ? parts[2] : ""); 208 + 209 + if (uri.includes("network.cosmik.collection")) { 210 + return `https://semble.so/profile/${userHandle}/collections/${rkey}`; 211 + } 212 + 213 + if (uri.includes("network.cosmik.card") && url) { 214 + return `https://semble.so/url?id=${encodeURIComponent(url)}`; 215 + } 216 + 217 + return `https://semble.so/profile/${userHandle}`; 218 + })(); 219 + 220 + const handleCopySemble = async () => { 221 + try { 222 + await navigator.clipboard.writeText(sembleUrl); 223 + setCopied(true); 224 + setTimeout(() => { 225 + setCopied(false); 226 + setIsOpen(false); 227 + }, 1500); 228 + } catch { 229 + prompt("Copy this link:", sembleUrl); 230 + } 231 + }; 232 + 233 return ( 234 <div className="share-menu-container" ref={menuRef}> 235 <button ··· 257 258 {isOpen && ( 259 <div className="share-menu"> 260 + {isSemble ? ( 261 + <> 262 + <div className="share-menu-section"> 263 + <div 264 + className="share-menu-label" 265 + style={{ display: "flex", alignItems: "center", gap: "6px" }} 266 + > 267 + <img 268 + src="/semble-logo.svg" 269 + alt="" 270 + style={{ width: "12px", height: "12px" }} 271 + /> 272 + Semble 273 + </div> 274 + <a 275 + href={sembleUrl} 276 + target="_blank" 277 + rel="noopener noreferrer" 278 + className="share-menu-item" 279 + style={{ textDecoration: "none" }} 280 + > 281 + <ExternalLink size={16} /> 282 + <span>Open on Semble</span> 283 + </a> 284 + <button className="share-menu-item" onClick={handleCopySemble}> 285 + {copied ? <Check size={16} /> : <Copy size={16} />} 286 + <span>{copied ? "Copied!" : "Copy Semble Link"}</span> 287 + </button> 288 + </div> 289 + <div className="share-menu-divider" /> 290 <button 291 className="share-menu-item" 292 + onClick={handleCopyAturi} 293 + title="Copy Universal URL" 294 > 295 + {copiedAturi ? <Check size={16} /> : <AturiIcon size={16} />} 296 + <span>{copiedAturi ? "Copied!" : "Copy Universal URL"}</span> 297 </button> 298 + </> 299 + ) : ( 300 + <> 301 + <div className="share-menu-section"> 302 + <div className="share-menu-label">Share to</div> 303 + {BLUESKY_FORKS.map((fork) => ( 304 + <button 305 + key={fork.domain} 306 + className="share-menu-item" 307 + onClick={() => handleShareToFork(fork.domain)} 308 + > 309 + <span className="share-menu-icon"> 310 + <fork.Icon /> 311 + </span> 312 + <span>{fork.name}</span> 313 + </button> 314 + ))} 315 + </div> 316 + <div className="share-menu-divider" /> 317 + <button className="share-menu-item" onClick={handleCopy}> 318 + {copied ? <Check size={16} /> : <Copy size={16} />} 319 + <span>{copied ? "Copied!" : "Copy Link"}</span> 320 + </button> 321 + <button 322 + className="share-menu-item" 323 + onClick={handleCopyAturi} 324 + title="Copy a universal link atproto link (via aturi.to)" 325 + > 326 + {copiedAturi ? <Check size={16} /> : <AturiIcon size={16} />} 327 + <span>{copiedAturi ? "Copied!" : "Copy Universal Link"}</span> 328 + </button> 329 + {navigator.share && ( 330 + <button className="share-menu-item" onClick={handleSystemShare}> 331 + <ExternalLink size={16} /> 332 + <span>More...</span> 333 + </button> 334 + )} 335 + </> 336 )} 337 </div> 338 )}
+47
web/src/css/feed.css
··· 206 justify-content: flex-end; 207 } 208 }
··· 206 justify-content: flex-end; 207 } 208 } 209 + 210 + .feed-tab { 211 + padding: 8px 16px; 212 + font-size: 1rem; 213 + font-weight: 500; 214 + color: var(--text-secondary); 215 + background: transparent; 216 + border: none; 217 + border-bottom: 2px solid transparent; 218 + cursor: pointer; 219 + transition: all 0.2s ease; 220 + margin-bottom: -1px; 221 + } 222 + 223 + .feed-tab:hover { 224 + color: var(--text-primary); 225 + } 226 + 227 + .feed-tab.active { 228 + color: var(--text-primary); 229 + border-bottom-color: var(--text-primary); 230 + font-weight: 600; 231 + } 232 + 233 + .filter-pill { 234 + padding: 6px 16px; 235 + font-size: 0.9rem; 236 + font-weight: 500; 237 + color: var(--text-secondary); 238 + background: var(--bg-tertiary); 239 + border: 1px solid transparent; 240 + border-radius: 999px; 241 + cursor: pointer; 242 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 243 + } 244 + 245 + .filter-pill:hover { 246 + background: var(--bg-secondary); 247 + color: var(--text-primary); 248 + border-color: var(--border); 249 + } 250 + 251 + .filter-pill.active { 252 + background: var(--text-primary); 253 + color: var(--bg-primary); 254 + font-weight: 600; 255 + }
+128 -84
web/src/pages/CollectionDetail.jsx
··· 1 - import { useState, useEffect, useCallback } from "react"; 2 import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3 - import { ArrowLeft, Edit2, Trash2, Plus } from "lucide-react"; 4 import { 5 - getCollections, 6 getCollectionItems, 7 removeItemFromCollection, 8 deleteCollection, ··· 27 const [error, setError] = useState(null); 28 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 29 30 const searchParams = new URLSearchParams(location.search); 31 const paramAuthorDid = searchParams.get("author"); 32 ··· 34 user?.did && 35 (collection?.creator?.did === user.did || paramAuthorDid === user.did); 36 37 - const fetchContext = useCallback(async () => { 38 - try { 39 - setLoading(true); 40 41 - let targetUri = null; 42 - let targetDid = paramAuthorDid || user?.did; 43 44 - if (handle && rkey) { 45 - try { 46 - targetDid = await resolveHandle(handle); 47 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 48 - } catch (e) { 49 - console.error("Failed to resolve handle", e); 50 } 51 - } else if (wildcardPath) { 52 - targetUri = decodeURIComponent(wildcardPath); 53 - } else if (rkey && targetDid) { 54 - targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 55 - } 56 57 - if (!targetUri) { 58 - if (!user && !handle && !paramAuthorDid) { 59 - setError("Please log in to view your collections"); 60 return; 61 } 62 - setError("Invalid collection URL"); 63 - return; 64 - } 65 66 - if (!targetDid && targetUri.startsWith("at://")) { 67 - const parts = targetUri.split("/"); 68 - if (parts.length > 2) targetDid = parts[2]; 69 - } 70 71 - if (!targetDid) { 72 - setError("Could not determine collection owner"); 73 - return; 74 - } 75 76 - const [cols, itemsData] = await Promise.all([ 77 - getCollections(targetDid), 78 - getCollectionItems(targetUri), 79 - ]); 80 81 - const found = 82 - cols.items?.find((c) => c.uri === targetUri) || 83 - cols.items?.find( 84 - (c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()), 85 - ); 86 87 - if (!found) { 88 - setError("Collection not found"); 89 - return; 90 } 91 - setCollection(found); 92 - setItems(itemsData || []); 93 - } catch (err) { 94 - console.error(err); 95 - setError("Failed to load collection"); 96 - } finally { 97 - setLoading(false); 98 - } 99 - }, [paramAuthorDid, user, handle, rkey, wildcardPath]); 100 101 - useEffect(() => { 102 fetchContext(); 103 - }, [fetchContext]); 104 105 const handleEditSuccess = () => { 106 - fetchContext(); 107 setIsEditModalOpen(false); 108 }; 109 110 const handleDeleteItem = async (itemUri) => { ··· 189 /> 190 {isOwner && ( 191 <> 192 - <button 193 - onClick={() => setIsEditModalOpen(true)} 194 - className="collection-detail-edit" 195 - title="Edit Collection" 196 - > 197 - <Edit2 size={18} /> 198 - </button> 199 - <button 200 - onClick={async () => { 201 - if (confirm("Delete this collection and all its items?")) { 202 - await deleteCollection(collection.uri); 203 - navigate("/collections"); 204 - } 205 - }} 206 - className="collection-detail-delete" 207 - title="Delete Collection" 208 - > 209 - <Trash2 size={18} /> 210 - </button> 211 </> 212 )} 213 </div> ··· 229 ) : ( 230 items.map((item) => ( 231 <div key={item.uri} className="collection-item-wrapper"> 232 - {isOwner && ( 233 - <button 234 - onClick={() => handleDeleteItem(item.uri)} 235 - className="collection-item-remove" 236 - title="Remove from collection" 237 - > 238 - <Trash2 size={14} /> 239 - </button> 240 - )} 241 242 {item.annotation ? ( 243 <AnnotationCard annotation={item.annotation} />
··· 1 + import { useState, useEffect } from "react"; 2 import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3 + import { ArrowLeft, Edit2, Trash2, Plus, ExternalLink } from "lucide-react"; 4 import { 5 + getCollection, 6 getCollectionItems, 7 removeItemFromCollection, 8 deleteCollection, ··· 27 const [error, setError] = useState(null); 28 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 29 30 + const [refreshTrigger, setRefreshTrigger] = useState(0); 31 + 32 const searchParams = new URLSearchParams(location.search); 33 const paramAuthorDid = searchParams.get("author"); 34 ··· 36 user?.did && 37 (collection?.creator?.did === user.did || paramAuthorDid === user.did); 38 39 + useEffect(() => { 40 + let active = true; 41 + 42 + const fetchContext = async () => { 43 + if (active) { 44 + setLoading(true); 45 + setError(null); 46 + } 47 48 + try { 49 + let targetUri = null; 50 + let targetDid = paramAuthorDid || user?.did; 51 52 + if (handle && rkey) { 53 + try { 54 + targetDid = await resolveHandle(handle); 55 + if (!active) return; 56 + targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 57 + } catch (e) { 58 + console.error("Failed to resolve handle", e); 59 + if (active) setError("Could not resolve user handle"); 60 + } 61 + } else if (wildcardPath) { 62 + targetUri = decodeURIComponent(wildcardPath); 63 + } else if (rkey && targetDid) { 64 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 65 } 66 67 + if (!targetUri) { 68 + if (active) { 69 + if (!user && !handle && !paramAuthorDid) { 70 + setError("Please log in to view your collections"); 71 + } else if (!error) { 72 + setError("Invalid collection URL"); 73 + } 74 + } 75 return; 76 } 77 78 + if (!targetDid && targetUri.startsWith("at://")) { 79 + const parts = targetUri.split("/"); 80 + if (parts.length > 2) targetDid = parts[2]; 81 + } 82 83 + const collectionData = await getCollection(targetUri); 84 + if (!active) return; 85 86 + setCollection(collectionData); 87 88 + const itemsData = await getCollectionItems(collectionData.uri); 89 + if (!active) return; 90 91 + setItems(itemsData || []); 92 + } catch (err) { 93 + console.error("Fetch failed:", err); 94 + if (active) { 95 + if ( 96 + err.message.includes("404") || 97 + err.message.includes("not found") 98 + ) { 99 + setError("Collection not found"); 100 + } else { 101 + setError(err.message || "Failed to load collection"); 102 + } 103 + } 104 + } finally { 105 + if (active) setLoading(false); 106 } 107 + }; 108 109 fetchContext(); 110 + 111 + return () => { 112 + active = false; 113 + }; 114 + }, [ 115 + paramAuthorDid, 116 + user?.did, 117 + handle, 118 + rkey, 119 + wildcardPath, 120 + refreshTrigger, 121 + error, 122 + user, 123 + ]); 124 125 const handleEditSuccess = () => { 126 setIsEditModalOpen(false); 127 + setRefreshTrigger((v) => v + 1); 128 }; 129 130 const handleDeleteItem = async (itemUri) => { ··· 209 /> 210 {isOwner && ( 211 <> 212 + {collection.uri.includes("network.cosmik.collection") ? ( 213 + <a 214 + href={`https://semble.so/profile/${collection.creator?.handle || collection.creator?.did}/collections/${collection.uri.split("/").pop()}`} 215 + target="_blank" 216 + rel="noopener noreferrer" 217 + className="collection-detail-edit btn btn-secondary btn-sm" 218 + style={{ 219 + textDecoration: "none", 220 + display: "flex", 221 + gap: "6px", 222 + alignItems: "center", 223 + }} 224 + title="Manage on Semble" 225 + > 226 + <span>Manage on Semble</span> 227 + <ExternalLink size={16} /> 228 + </a> 229 + ) : ( 230 + <> 231 + <button 232 + onClick={() => setIsEditModalOpen(true)} 233 + className="collection-detail-edit" 234 + title="Edit Collection" 235 + > 236 + <Edit2 size={18} /> 237 + </button> 238 + <button 239 + onClick={async () => { 240 + if ( 241 + confirm("Delete this collection and all its items?") 242 + ) { 243 + await deleteCollection(collection.uri); 244 + navigate("/collections"); 245 + } 246 + }} 247 + className="collection-detail-delete" 248 + title="Delete Collection" 249 + > 250 + <Trash2 size={18} /> 251 + </button> 252 + </> 253 + )} 254 </> 255 )} 256 </div> ··· 272 ) : ( 273 items.map((item) => ( 274 <div key={item.uri} className="collection-item-wrapper"> 275 + {isOwner && 276 + !collection.uri.includes("network.cosmik.collection") && ( 277 + <button 278 + onClick={() => handleDeleteItem(item.uri)} 279 + className="collection-item-remove" 280 + title="Remove from collection" 281 + > 282 + <Trash2 size={14} /> 283 + </button> 284 + )} 285 286 {item.annotation ? ( 287 <AnnotationCard annotation={item.annotation} />
+70 -21
web/src/pages/Feed.jsx
··· 18 return localStorage.getItem("feedFilter") || "all"; 19 }); 20 21 const [annotations, setAnnotations] = useState([]); 22 const [loading, setLoading] = useState(true); 23 const [error, setError] = useState(null); ··· 26 localStorage.setItem("feedFilter", filter); 27 }, [filter]); 28 29 const [collectionModalState, setCollectionModalState] = useState({ 30 isOpen: false, 31 uri: null, ··· 39 setLoading(true); 40 let creatorDid = ""; 41 42 - if (filter === "my-tags") { 43 if (user?.did) { 44 creatorDid = user.did; 45 } else { ··· 54 0, 55 tagFilter || "", 56 creatorDid, 57 ); 58 setAnnotations(data.items || []); 59 } catch (err) { ··· 63 } 64 } 65 fetchFeed(); 66 - }, [tagFilter, filter, user]); 67 68 const filteredAnnotations = 69 - filter === "all" || filter === "my-tags" 70 - ? annotations 71 - : annotations.filter((a) => { 72 - if (filter === "commenting") 73 - return a.motivation === "commenting" || a.type === "Annotation"; 74 - if (filter === "highlighting") 75 - return a.motivation === "highlighting" || a.type === "Highlight"; 76 - if (filter === "bookmarking") 77 - return a.motivation === "bookmarking" || a.type === "Bookmark"; 78 - return a.motivation === filter; 79 - }); 80 81 return ( 82 <div className="feed-page"> ··· 117 </div> 118 119 {} 120 - <div className="feed-filters"> 121 <button 122 - className={`filter-tab ${filter === "all" ? "active" : ""}`} 123 - onClick={() => setFilter("all")} 124 > 125 All 126 </button> 127 {user && ( 128 <button 129 - className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 130 - onClick={() => setFilter("my-tags")} 131 > 132 My Feed 133 </button> 134 )} 135 <button 136 - className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 137 onClick={() => setFilter("commenting")} 138 > 139 Annotations 140 </button> 141 <button 142 - className={`filter-tab ${filter === "highlighting" ? "active" : ""}`} 143 onClick={() => setFilter("highlighting")} 144 > 145 Highlights 146 </button> 147 <button 148 - className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`} 149 onClick={() => setFilter("bookmarking")} 150 > 151 Bookmarks
··· 18 return localStorage.getItem("feedFilter") || "all"; 19 }); 20 21 + const [feedType, setFeedType] = useState(() => { 22 + return localStorage.getItem("feedType") || "all"; 23 + }); 24 + 25 const [annotations, setAnnotations] = useState([]); 26 const [loading, setLoading] = useState(true); 27 const [error, setError] = useState(null); ··· 30 localStorage.setItem("feedFilter", filter); 31 }, [filter]); 32 33 + useEffect(() => { 34 + localStorage.setItem("feedType", feedType); 35 + }, [feedType]); 36 + 37 const [collectionModalState, setCollectionModalState] = useState({ 38 isOpen: false, 39 uri: null, ··· 47 setLoading(true); 48 let creatorDid = ""; 49 50 + if (feedType === "my-feed") { 51 if (user?.did) { 52 creatorDid = user.did; 53 } else { ··· 62 0, 63 tagFilter || "", 64 creatorDid, 65 + feedType, 66 + filter !== "all" ? filter : "", 67 ); 68 setAnnotations(data.items || []); 69 } catch (err) { ··· 73 } 74 } 75 fetchFeed(); 76 + }, [tagFilter, filter, feedType, user]); 77 78 const filteredAnnotations = 79 + feedType === "all" || 80 + feedType === "popular" || 81 + feedType === "semble" || 82 + feedType === "margin" || 83 + feedType === "my-feed" 84 + ? filter === "all" 85 + ? annotations 86 + : annotations.filter((a) => { 87 + if (filter === "commenting") 88 + return a.motivation === "commenting" || a.type === "Annotation"; 89 + if (filter === "highlighting") 90 + return a.motivation === "highlighting" || a.type === "Highlight"; 91 + if (filter === "bookmarking") 92 + return a.motivation === "bookmarking" || a.type === "Bookmark"; 93 + return a.motivation === filter; 94 + }) 95 + : annotations; 96 97 return ( 98 <div className="feed-page"> ··· 133 </div> 134 135 {} 136 + <div 137 + className="feed-filters" 138 + style={{ 139 + marginBottom: "12px", 140 + borderBottom: "1px solid var(--border)", 141 + }} 142 + > 143 <button 144 + className={`filter-tab ${feedType === "all" ? "active" : ""}`} 145 + onClick={() => setFeedType("all")} 146 > 147 All 148 </button> 149 + <button 150 + className={`filter-tab ${feedType === "popular" ? "active" : ""}`} 151 + onClick={() => setFeedType("popular")} 152 + > 153 + Popular 154 + </button> 155 + <button 156 + className={`filter-tab ${feedType === "margin" ? "active" : ""}`} 157 + onClick={() => setFeedType("margin")} 158 + > 159 + Margin 160 + </button> 161 + <button 162 + className={`filter-tab ${feedType === "semble" ? "active" : ""}`} 163 + onClick={() => setFeedType("semble")} 164 + > 165 + Semble 166 + </button> 167 {user && ( 168 <button 169 + className={`filter-tab ${feedType === "my-feed" ? "active" : ""}`} 170 + onClick={() => setFeedType("my-feed")} 171 > 172 My Feed 173 </button> 174 )} 175 + </div> 176 + 177 + <div className="feed-filters"> 178 <button 179 + className={`filter-pill ${filter === "all" ? "active" : ""}`} 180 + onClick={() => setFilter("all")} 181 + > 182 + All Types 183 + </button> 184 + <button 185 + className={`filter-pill ${filter === "commenting" ? "active" : ""}`} 186 onClick={() => setFilter("commenting")} 187 > 188 Annotations 189 </button> 190 <button 191 + className={`filter-pill ${filter === "highlighting" ? "active" : ""}`} 192 onClick={() => setFilter("highlighting")} 193 > 194 Highlights 195 </button> 196 <button 197 + className={`filter-pill ${filter === "bookmarking" ? "active" : ""}`} 198 onClick={() => setFilter("bookmarking")} 199 > 200 Bookmarks