(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement Semble cards and collections to Margin

scanash00 453c5db8 87cca4da

+1396 -268
+71 -43
backend/internal/api/collections.go
··· 1 1 package api 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 6 + "fmt" 5 7 "log" 6 8 "net/http" 7 9 "net/url" ··· 286 288 return 287 289 } 288 290 289 - enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 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 + } 290 303 291 304 session, err := s.refresher.GetSessionWithAutoRefresh(r) 292 305 viewerDID := "" ··· 294 307 viewerDID = session.DID 295 308 } 296 309 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 - } 310 + enrichedItems, err := hydrateCollectionItems(s.db, items, viewerDID) 311 + if err != nil { 312 + log.Printf("Hydration error: %v", err) 313 + enrichedItems = []APICollectionItem{} 339 314 } 340 315 341 316 w.Header().Set("Content-Type", "application/json") ··· 466 441 w.WriteHeader(http.StatusOK) 467 442 json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 468 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 1 package api 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 6 + "fmt" 5 7 "io" 6 8 "log" 7 9 "net/http" ··· 62 64 r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) 63 65 r.Delete("/collections/items", collectionService.RemoveCollectionItem) 64 66 r.Get("/collections/containing", collectionService.GetAnnotationCollections) 67 + r.Get("/collection", collectionService.GetCollection) 65 68 r.Post("/sync", h.SyncAll) 66 69 67 70 r.Get("/targets", h.GetByTarget) ··· 138 141 limit := parseIntParam(r, "limit", 50) 139 142 tag := r.URL.Query().Get("tag") 140 143 creator := r.URL.Query().Get("creator") 144 + feedType := r.URL.Query().Get("type") 141 145 142 146 viewerDID := h.getViewerDID(r) 143 147 144 - if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "")) { 148 + if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 145 149 if creator == viewerDID { 146 150 h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 147 151 return ··· 154 158 var collectionItems []db.CollectionItem 155 159 var err error 156 160 161 + motivation := r.URL.Query().Get("motivation") 162 + 157 163 if tag != "" { 158 164 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) 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 + } 162 174 collectionItems = []db.CollectionItem{} 163 175 } 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) 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 + } 167 185 collectionItems = []db.CollectionItem{} 168 186 } 169 187 } 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) 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 + } 173 197 collectionItems = []db.CollectionItem{} 174 198 } 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) 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 + } 181 213 } 182 214 } 183 215 ··· 185 217 authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 186 218 authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 187 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 + 188 234 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 189 235 190 236 var feed []interface{} ··· 201 247 feed = append(feed, ci) 202 248 } 203 249 204 - sortFeed(feed) 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 + } 205 285 206 286 if len(feed) > limit { 207 287 feed = feed[:limit] ··· 288 368 h.db.CreateBookmark(&b) 289 369 } 290 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 + } 291 395 292 396 authAnnos, _ := hydrateAnnotations(h.db, annotations, did) 293 397 authHighs, _ := hydrateHighlights(h.db, highlights, did) 294 398 authBooks, _ := hydrateBookmarks(h.db, bookmarks, did) 399 + authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, did) 295 400 296 401 var feed []interface{} 297 402 for _, a := range authAnnos { ··· 302 407 } 303 408 for _, b := range authBooks { 304 409 feed = append(feed, b) 410 + } 411 + for _, ci := range authCollectionItems { 412 + feed = append(feed, ci) 305 413 } 306 414 307 415 sortFeed(feed) ··· 363 471 } 364 472 } 365 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 + 366 511 func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 367 512 uri := r.URL.Query().Get("uri") 368 513 if uri == "" { ··· 400 545 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 401 546 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 402 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 + } 403 575 } 404 576 } 405 577 } ··· 530 702 viewerDID := h.getViewerDID(r) 531 703 532 704 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 - } 705 + go func() { 706 + if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit); err != nil { 707 + log.Printf("Background sync error (annotations): %v", err) 539 708 } 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) 709 + }() 551 710 } 711 + 712 + annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 552 713 553 714 if err != nil { 554 715 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 581 742 viewerDID := h.getViewerDID(r) 582 743 583 744 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 - } 745 + go func() { 746 + if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit); err != nil { 747 + log.Printf("Background sync error (highlights): %v", err) 590 748 } 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) 749 + }() 602 750 } 751 + 752 + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 603 753 604 754 if err != nil { 605 755 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 632 782 viewerDID := h.getViewerDID(r) 633 783 634 784 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 - } 785 + go func() { 786 + if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit); err != nil { 787 + log.Printf("Background sync error (bookmarks): %v", err) 641 788 } 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) 789 + }() 653 790 } 791 + 792 + bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 654 793 655 794 if err != nil { 656 795 http.Error(w, err.Error(), http.StatusInternalServerError)
+14
backend/internal/api/hydration.go
··· 549 549 highlightURIs = append(highlightURIs, item.AnnotationURI) 550 550 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 551 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) 552 555 } 553 556 } 554 557 ··· 633 636 apiItem.Highlight = &val 634 637 } else if val, ok := bookmarksMap[item.AnnotationURI]; ok { 635 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 + } 636 650 } 637 651 638 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 16 ) 17 17 18 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" 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" 27 29 ) 28 30 29 31 var RelayURL = "wss://jetstream2.us-east.bsky.network/subscribe" ··· 52 54 i.RegisterHandler(CollectionCollection, i.handleCollection) 53 55 i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem) 54 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) 55 60 56 61 return i 57 62 } ··· 235 240 i.db.RemoveFromCollection(uri) 236 241 case CollectionProfile: 237 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 + 238 251 } 239 252 } 240 253 ··· 687 700 log.Printf("Indexed profile from %s", event.Repo) 688 701 } 689 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 6 "fmt" 7 7 "io" 8 8 "net/http" 9 + "strings" 9 10 "time" 10 11 11 12 "margin.at/internal/db" ··· 29 30 xrpc.CollectionLike, 30 31 xrpc.CollectionCollection, 31 32 xrpc.CollectionCollectionItem, 33 + xrpc.CollectionSembleCard, 34 + xrpc.CollectionSembleCollection, 35 + xrpc.CollectionSembleCollectionLink, 32 36 } 33 37 34 38 results := make(map[string]string) ··· 101 105 switch collectionNSID { 102 106 case xrpc.CollectionAnnotation: 103 107 localURIs, err = s.db.GetAnnotationURIs(did) 108 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionAnnotation) 104 109 case xrpc.CollectionHighlight: 105 110 localURIs, err = s.db.GetHighlightURIs(did) 111 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionHighlight) 106 112 case xrpc.CollectionBookmark: 107 113 localURIs, err = s.db.GetBookmarkURIs(did) 114 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionBookmark) 108 115 case xrpc.CollectionCollection: 109 116 cols, e := s.db.GetCollectionsByAuthor(did) 110 117 if e == nil { 111 118 for _, c := range cols { 112 119 localURIs = append(localURIs, c.URI) 113 120 } 121 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionCollection) 114 122 } else { 115 123 err = e 116 124 } ··· 120 128 for _, item := range items { 121 129 localURIs = append(localURIs, item.URI) 122 130 } 131 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionCollectionItem) 123 132 } else { 124 133 err = e 125 134 } ··· 129 138 for _, r := range replies { 130 139 localURIs = append(localURIs, r.URI) 131 140 } 141 + localURIs = filterURIsByCollection(localURIs, xrpc.CollectionReply) 132 142 } else { 133 143 err = e 134 144 } ··· 138 148 for _, l := range likes { 139 149 localURIs = append(localURIs, l.URI) 140 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) 141 186 } else { 142 187 err = e 143 188 } ··· 161 206 _ = s.db.DeleteReply(uri) 162 207 case xrpc.CollectionLike: 163 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) 164 216 } 165 217 deletedCount++ 166 218 } ··· 173 225 } 174 226 } 175 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 176 242 } 177 243 178 244 func strPtr(s string) *string { ··· 422 488 SubjectURI: record.Subject.URI, 423 489 CreatedAt: createdAt, 424 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(), 425 604 }) 426 605 } 427 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 28 offset = 0, 29 29 tag = "", 30 30 creator = "", 31 + feedType = "", 32 + motivation = "", 31 33 ) { 32 34 let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; 33 35 if (tag) url += `&tag=${encodeURIComponent(tag)}`; 34 36 if (creator) url += `&creator=${encodeURIComponent(creator)}`; 37 + if (feedType) url += `&type=${encodeURIComponent(feedType)}`; 38 + if (motivation) url += `&motivation=${encodeURIComponent(motivation)}`; 35 39 return request(url); 36 40 } 37 41 ··· 135 139 let url = `${API_BASE}/collections`; 136 140 if (did) url += `?author=${encodeURIComponent(did)}`; 137 141 return request(url); 142 + } 143 + 144 + export async function getCollection(uri) { 145 + return request(`${API_BASE}/collection?uri=${encodeURIComponent(uri)}`); 138 146 } 139 147 140 148 export async function getCollectionsContaining(annotationUri) {
+44 -3
web/src/components/AnnotationCard.jsx
··· 224 224 <UserMeta author={data.author} createdAt={data.createdAt} /> 225 225 </div> 226 226 <div className="annotation-header-right"> 227 - <div style={{ display: "flex", gap: "4px" }}> 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 + )} 228 248 {hasEditHistory && !data.color && !data.description && ( 229 249 <button 230 250 className="annotation-action action-icon-only" ··· 235 255 </button> 236 256 )} 237 257 238 - {isOwner && ( 258 + {isOwner && !(data.uri && data.uri.includes("network.cosmik")) && ( 239 259 <> 240 260 {!data.color && !data.description && ( 241 261 <button ··· 407 427 text={data.title || data.url} 408 428 handle={data.author?.handle} 409 429 type="Annotation" 430 + url={data.url} 410 431 /> 411 432 <button 412 433 className="annotation-action" ··· 557 578 </div> 558 579 559 580 <div className="annotation-header-right"> 560 - <div style={{ display: "flex", gap: "4px" }}> 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 + )} 561 602 {isOwner && ( 562 603 <> 563 604 <button
+34 -9
web/src/components/BookmarkCard.jsx
··· 105 105 </div> 106 106 107 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" 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" 115 120 > 116 - <TrashIcon size={16} /> 117 - </button> 121 + <span>via Semble</span> 122 + <img 123 + src="/semble-logo.svg" 124 + alt="Semble" 125 + style={{ width: "16px", height: "16px" }} 126 + /> 127 + </div> 118 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> 119 143 </div> 120 144 </div> 121 145 </header> ··· 164 188 text={data.title || data.description} 165 189 handle={data.author?.handle} 166 190 type="Bookmark" 191 + url={data.url} 167 192 /> 168 193 <button 169 194 className="annotation-action"
+11
web/src/components/CollectionIcon.jsx
··· 89 89 return <Folder size={size} className={className} />; 90 90 } 91 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 + 92 103 if (icon.startsWith("icon:")) { 93 104 const iconName = icon.replace("icon:", ""); 94 105 const IconComponent = ICON_MAP[iconName];
+1 -1
web/src/components/CollectionRow.jsx
··· 24 24 </div> 25 25 <ChevronRight size={20} className="collection-row-arrow" /> 26 26 </Link> 27 - {onEdit && ( 27 + {onEdit && !collection.uri.includes("network.cosmik") && ( 28 28 <button 29 29 onClick={(e) => { 30 30 e.preventDefault();
+109 -31
web/src/components/ShareMenu.jsx
··· 97 97 { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 98 ]; 99 99 100 - export default function ShareMenu({ uri, text, customUrl, handle, type }) { 100 + export default function ShareMenu({ uri, text, customUrl, handle, type, url }) { 101 101 const [isOpen, setIsOpen] = useState(false); 102 102 const [copied, setCopied] = useState(false); 103 103 const [copiedAturi, setCopiedAturi] = useState(false); ··· 109 109 110 110 const uriParts = uri.split("/"); 111 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 + } 112 117 113 118 if (handle && type) { 114 119 return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 115 120 } 116 121 117 - const did = uriParts[2]; 118 122 return `${window.location.origin}/at/${did}/${rkey}`; 119 123 }; 120 124 ··· 195 199 setIsOpen(false); 196 200 }; 197 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 + 198 233 return ( 199 234 <div className="share-menu-container" ref={menuRef}> 200 235 <button ··· 222 257 223 258 {isOpen && ( 224 259 <div className="share-menu"> 225 - <div className="share-menu-section"> 226 - <div className="share-menu-label">Share to</div> 227 - {BLUESKY_FORKS.map((fork) => ( 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" /> 228 290 <button 229 - key={fork.domain} 230 291 className="share-menu-item" 231 - onClick={() => handleShareToFork(fork.domain)} 292 + onClick={handleCopyAturi} 293 + title="Copy Universal URL" 232 294 > 233 - <span className="share-menu-icon"> 234 - <fork.Icon /> 235 - </span> 236 - <span>{fork.name}</span> 295 + {copiedAturi ? <Check size={16} /> : <AturiIcon size={16} />} 296 + <span>{copiedAturi ? "Copied!" : "Copy Universal URL"}</span> 237 297 </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> 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 + </> 258 336 )} 259 337 </div> 260 338 )}
+47
web/src/css/feed.css
··· 206 206 justify-content: flex-end; 207 207 } 208 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"; 1 + import { useState, useEffect } from "react"; 2 2 import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3 - import { ArrowLeft, Edit2, Trash2, Plus } from "lucide-react"; 3 + import { ArrowLeft, Edit2, Trash2, Plus, ExternalLink } from "lucide-react"; 4 4 import { 5 - getCollections, 5 + getCollection, 6 6 getCollectionItems, 7 7 removeItemFromCollection, 8 8 deleteCollection, ··· 27 27 const [error, setError] = useState(null); 28 28 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 29 29 30 + const [refreshTrigger, setRefreshTrigger] = useState(0); 31 + 30 32 const searchParams = new URLSearchParams(location.search); 31 33 const paramAuthorDid = searchParams.get("author"); 32 34 ··· 34 36 user?.did && 35 37 (collection?.creator?.did === user.did || paramAuthorDid === user.did); 36 38 37 - const fetchContext = useCallback(async () => { 38 - try { 39 - setLoading(true); 39 + useEffect(() => { 40 + let active = true; 41 + 42 + const fetchContext = async () => { 43 + if (active) { 44 + setLoading(true); 45 + setError(null); 46 + } 40 47 41 - let targetUri = null; 42 - let targetDid = paramAuthorDid || user?.did; 48 + try { 49 + let targetUri = null; 50 + let targetDid = paramAuthorDid || user?.did; 43 51 44 - if (handle && rkey) { 45 - try { 46 - targetDid = await resolveHandle(handle); 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) { 47 64 targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 48 - } catch (e) { 49 - console.error("Failed to resolve handle", e); 50 65 } 51 - } else if (wildcardPath) { 52 - targetUri = decodeURIComponent(wildcardPath); 53 - } else if (rkey && targetDid) { 54 - targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 55 - } 56 66 57 - if (!targetUri) { 58 - if (!user && !handle && !paramAuthorDid) { 59 - setError("Please log in to view your collections"); 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 + } 60 75 return; 61 76 } 62 - setError("Invalid collection URL"); 63 - return; 64 - } 65 77 66 - if (!targetDid && targetUri.startsWith("at://")) { 67 - const parts = targetUri.split("/"); 68 - if (parts.length > 2) targetDid = parts[2]; 69 - } 78 + if (!targetDid && targetUri.startsWith("at://")) { 79 + const parts = targetUri.split("/"); 80 + if (parts.length > 2) targetDid = parts[2]; 81 + } 70 82 71 - if (!targetDid) { 72 - setError("Could not determine collection owner"); 73 - return; 74 - } 83 + const collectionData = await getCollection(targetUri); 84 + if (!active) return; 75 85 76 - const [cols, itemsData] = await Promise.all([ 77 - getCollections(targetDid), 78 - getCollectionItems(targetUri), 79 - ]); 86 + setCollection(collectionData); 80 87 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 - ); 88 + const itemsData = await getCollectionItems(collectionData.uri); 89 + if (!active) return; 86 90 87 - if (!found) { 88 - setError("Collection not found"); 89 - return; 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); 90 106 } 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]); 107 + }; 100 108 101 - useEffect(() => { 102 109 fetchContext(); 103 - }, [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 + ]); 104 124 105 125 const handleEditSuccess = () => { 106 - fetchContext(); 107 126 setIsEditModalOpen(false); 127 + setRefreshTrigger((v) => v + 1); 108 128 }; 109 129 110 130 const handleDeleteItem = async (itemUri) => { ··· 189 209 /> 190 210 {isOwner && ( 191 211 <> 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> 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 + )} 211 254 </> 212 255 )} 213 256 </div> ··· 229 272 ) : ( 230 273 items.map((item) => ( 231 274 <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 - )} 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 + )} 241 285 242 286 {item.annotation ? ( 243 287 <AnnotationCard annotation={item.annotation} />
+70 -21
web/src/pages/Feed.jsx
··· 18 18 return localStorage.getItem("feedFilter") || "all"; 19 19 }); 20 20 21 + const [feedType, setFeedType] = useState(() => { 22 + return localStorage.getItem("feedType") || "all"; 23 + }); 24 + 21 25 const [annotations, setAnnotations] = useState([]); 22 26 const [loading, setLoading] = useState(true); 23 27 const [error, setError] = useState(null); ··· 26 30 localStorage.setItem("feedFilter", filter); 27 31 }, [filter]); 28 32 33 + useEffect(() => { 34 + localStorage.setItem("feedType", feedType); 35 + }, [feedType]); 36 + 29 37 const [collectionModalState, setCollectionModalState] = useState({ 30 38 isOpen: false, 31 39 uri: null, ··· 39 47 setLoading(true); 40 48 let creatorDid = ""; 41 49 42 - if (filter === "my-tags") { 50 + if (feedType === "my-feed") { 43 51 if (user?.did) { 44 52 creatorDid = user.did; 45 53 } else { ··· 54 62 0, 55 63 tagFilter || "", 56 64 creatorDid, 65 + feedType, 66 + filter !== "all" ? filter : "", 57 67 ); 58 68 setAnnotations(data.items || []); 59 69 } catch (err) { ··· 63 73 } 64 74 } 65 75 fetchFeed(); 66 - }, [tagFilter, filter, user]); 76 + }, [tagFilter, filter, feedType, user]); 67 77 68 78 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 - }); 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; 80 96 81 97 return ( 82 98 <div className="feed-page"> ··· 117 133 </div> 118 134 119 135 {} 120 - <div className="feed-filters"> 136 + <div 137 + className="feed-filters" 138 + style={{ 139 + marginBottom: "12px", 140 + borderBottom: "1px solid var(--border)", 141 + }} 142 + > 121 143 <button 122 - className={`filter-tab ${filter === "all" ? "active" : ""}`} 123 - onClick={() => setFilter("all")} 144 + className={`filter-tab ${feedType === "all" ? "active" : ""}`} 145 + onClick={() => setFeedType("all")} 124 146 > 125 147 All 126 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> 127 167 {user && ( 128 168 <button 129 - className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 130 - onClick={() => setFilter("my-tags")} 169 + className={`filter-tab ${feedType === "my-feed" ? "active" : ""}`} 170 + onClick={() => setFeedType("my-feed")} 131 171 > 132 172 My Feed 133 173 </button> 134 174 )} 175 + </div> 176 + 177 + <div className="feed-filters"> 135 178 <button 136 - className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 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" : ""}`} 137 186 onClick={() => setFilter("commenting")} 138 187 > 139 188 Annotations 140 189 </button> 141 190 <button 142 - className={`filter-tab ${filter === "highlighting" ? "active" : ""}`} 191 + className={`filter-pill ${filter === "highlighting" ? "active" : ""}`} 143 192 onClick={() => setFilter("highlighting")} 144 193 > 145 194 Highlights 146 195 </button> 147 196 <button 148 - className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`} 197 + className={`filter-pill ${filter === "bookmarking" ? "active" : ""}`} 149 198 onClick={() => setFilter("bookmarking")} 150 199 > 151 200 Bookmarks