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

Configure Feed

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

improvemetns and stuff

+267 -112
+24 -9
avatar/worker.js
··· 17 17 const { pathname, searchParams } = url; 18 18 19 19 if (!pathname || pathname === "/") { 20 - return new Response(`This is Margin's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. 20 + return new Response(`This is Margin's avatar service. It fetches avatars directly from the AT Protocol PDS and caches them on Cloudflare. 21 21 You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`); 22 22 } 23 23 ··· 74 74 } catch (e) {} 75 75 76 76 if (!avatarUrl) { 77 - const profileResponse = await fetch( 78 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${decodedActor}`, 79 - ); 80 - 81 - if (profileResponse.ok) { 82 - const profile = await profileResponse.json(); 83 - avatarUrl = profile.avatar; 84 - } 77 + try { 78 + const identityResp = await fetch( 79 + `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(decodedActor)}`, 80 + ); 81 + if (identityResp.ok) { 82 + const identity = await identityResp.json(); 83 + const did = identity.did; 84 + const pds = identity.pds; 85 + if (did && pds) { 86 + const profileResp = await fetch( 87 + `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 88 + ); 89 + if (profileResp.ok) { 90 + const profileRecord = await profileResp.json(); 91 + const avatarBlob = profileRecord?.value?.avatar; 92 + const cid = avatarBlob?.ref?.$link; 93 + if (cid) { 94 + avatarUrl = `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 95 + } 96 + } 97 + } 98 + } 99 + } catch (e) {} 85 100 } 86 101 87 102 if (!avatarUrl) {
+90 -6
backend/internal/api/handler.go
··· 8 8 "io" 9 9 "net/http" 10 10 "net/url" 11 + "sort" 11 12 "strconv" 12 13 "strings" 13 14 "sync" ··· 107 108 sessionRepo := postgres.NewSessionRepository(database.DB) 108 109 profileRepo := &fullProfileRepository{db: database} // rich resolution: cache → DB → bsky.social 109 110 profileSvc := service.NewProfileService(profileRepo) // service-lifetime TTL cache on top 110 - hydration := service.NewHydrationService(engagementRepo, profileSvc) 111 + collectionRepo := postgres.NewCollectionRepository(database.DB) 112 + hydration := service.NewHydrationService(engagementRepo, profileSvc, collectionRepo) 111 113 feedSvc := service.NewFeedService(noteRepo, hydration, database) 112 114 113 115 return &Handler{ ··· 392 394 creator := r.URL.Query().Get("creator") 393 395 motivation := r.URL.Query().Get("motivation") 394 396 feedTypeStr := r.URL.Query().Get("type") 397 + feedType := parseFeedType(feedTypeStr) 395 398 396 399 var motivations []string 397 400 if motivation != "" { 398 401 motivations = []string{motivation} 399 402 } 400 403 404 + fetchLimit := limit + offset 401 405 req := service.FeedRequest{ 402 406 ViewerDID: h.getViewerDID(r), 403 407 Motivations: motivations, 404 408 AuthorDID: creator, 405 409 Tag: tag, 406 - FeedType: parseFeedType(feedTypeStr), 407 - Limit: limit, 408 - Offset: offset, 410 + FeedType: feedType, 411 + Limit: fetchLimit, 412 + Offset: 0, 409 413 } 410 414 411 415 resp, err := h.feedSvc.GetFeed(r.Context(), req) ··· 414 418 return 415 419 } 416 420 421 + allItems := make([]interface{}, len(resp.Items)) 422 + for i, n := range resp.Items { 423 + allItems[i] = n 424 + } 425 + 426 + if feedType == db.FeedTypeRecent && motivation == "" && tag == "" { 427 + var collItems []db.CollectionItem 428 + if creator != "" { 429 + collItems, _ = h.db.GetCollectionItemsByAuthor(creator) 430 + if len(collItems) > fetchLimit { 431 + collItems = collItems[:fetchLimit] 432 + } 433 + } else { 434 + collItems, _ = h.db.GetRecentCollectionItems(fetchLimit, 0) 435 + } 436 + 437 + if len(collItems) > 0 { 438 + viewerDID := h.getViewerDID(r) 439 + hydrated, hydrateErr := hydrateCollectionItems(h.db, collItems, viewerDID) 440 + if hydrateErr == nil { 441 + noteURIsInFeed := make(map[string]bool, len(resp.Items)) 442 + for _, n := range resp.Items { 443 + noteURIsInFeed[n.ID] = true 444 + } 445 + for _, ci := range hydrated { 446 + innerURI := collectionItemInnerURI(ci) 447 + if innerURI != "" && noteURIsInFeed[innerURI] { 448 + delete(noteURIsInFeed, innerURI) 449 + for idx, item := range allItems { 450 + if n, ok := item.(service.APINote); ok && n.ID == innerURI { 451 + allItems = append(allItems[:idx], allItems[idx+1:]...) 452 + break 453 + } 454 + } 455 + } 456 + allItems = append(allItems, ci) 457 + } 458 + } 459 + } 460 + 461 + sort.Slice(allItems, func(i, j int) bool { 462 + return feedItemCreatedAt(allItems[i]).After(feedItemCreatedAt(allItems[j])) 463 + }) 464 + } 465 + 466 + if offset < len(allItems) { 467 + allItems = allItems[offset:] 468 + } else { 469 + allItems = nil 470 + } 471 + if len(allItems) > limit { 472 + allItems = allItems[:limit] 473 + } 474 + if allItems == nil { 475 + allItems = []interface{}{} 476 + } 477 + 417 478 WriteSuccess(w, map[string]interface{}{ 418 479 "@context": "http://www.w3.org/ns/anno.jsonld", 419 480 "type": "Collection", 420 - "items": resp.Items, 421 - "totalItems": resp.TotalItems, 481 + "items": allItems, 482 + "totalItems": len(allItems), 422 483 }) 484 + } 485 + 486 + func collectionItemInnerURI(ci APICollectionItem) string { 487 + if ci.Annotation != nil { 488 + return ci.Annotation.ID 489 + } 490 + if ci.Highlight != nil { 491 + return ci.Highlight.ID 492 + } 493 + if ci.Bookmark != nil { 494 + return ci.Bookmark.ID 495 + } 496 + return "" 497 + } 498 + 499 + func feedItemCreatedAt(item interface{}) time.Time { 500 + switch v := item.(type) { 501 + case service.APINote: 502 + return v.CreatedAt 503 + case APICollectionItem: 504 + return v.CreatedAt 505 + } 506 + return time.Time{} 423 507 } 424 508 func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 425 509 uri := r.URL.Query().Get("uri")
+4
backend/internal/domain/interfaces.go
··· 60 60 GetSession(ctx context.Context, id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) 61 61 } 62 62 63 + type CollectionRepository interface { 64 + GetCollectionsForNoteURIs(ctx context.Context, noteURIs []string) (map[string]Collection, error) 65 + } 66 + 63 67 type NoteService interface{} 64 68 65 69 type ProfileService interface{}
+49
backend/internal/repository/postgres/pg_collection_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "margin.at/internal/domain" 8 + ) 9 + 10 + type CollectionRepository struct { 11 + db DB 12 + } 13 + 14 + func NewCollectionRepository(db DB) *CollectionRepository { 15 + return &CollectionRepository{db: db} 16 + } 17 + 18 + func (r *CollectionRepository) GetCollectionsForNoteURIs(ctx context.Context, noteURIs []string) (map[string]domain.Collection, error) { 19 + if len(noteURIs) == 0 { 20 + return map[string]domain.Collection{}, nil 21 + } 22 + rows, err := r.db.QueryContext(ctx, ` 23 + SELECT DISTINCT ON (ci.annotation_uri) 24 + ci.annotation_uri, 25 + c.uri, c.author_did, c.name, c.description, c.icon, c.created_at, c.indexed_at 26 + FROM collection_items ci 27 + JOIN collections c ON c.uri = ci.collection_uri 28 + WHERE ci.annotation_uri = ANY($1) 29 + ORDER BY ci.annotation_uri, ci.created_at ASC 30 + `, pqArray(noteURIs)) 31 + if err != nil { 32 + return nil, err 33 + } 34 + defer rows.Close() 35 + 36 + result := make(map[string]domain.Collection) 37 + for rows.Next() { 38 + var noteURI string 39 + var c domain.Collection 40 + var createdAt, indexedAt time.Time 41 + if err := rows.Scan(&noteURI, &c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &createdAt, &indexedAt); err != nil { 42 + return nil, err 43 + } 44 + c.CreatedAt = createdAt 45 + c.IndexedAt = indexedAt 46 + result[noteURI] = c 47 + } 48 + return result, nil 49 + }
+27 -22
backend/internal/service/hydration.go
··· 51 51 } 52 52 53 53 type APINote struct { 54 - ID string `json:"id"` 55 - CID string `json:"cid,omitempty"` 56 - Type string `json:"type"` 57 - Motivation string `json:"motivation,omitempty"` 58 - Author domain.Author `json:"creator"` 59 - Body *APIBody `json:"body,omitempty"` 60 - Target APITarget `json:"target"` 61 - Color string `json:"color,omitempty"` 62 - Description string `json:"description,omitempty"` 63 - Tags []string `json:"tags,omitempty"` 64 - Generator *APIGenerator `json:"generator,omitempty"` 65 - CreatedAt time.Time `json:"created"` 66 - IndexedAt time.Time `json:"indexed"` 67 - LikeCount int `json:"likeCount"` 68 - ReplyCount int `json:"replyCount"` 69 - ViewerHasLiked bool `json:"viewerHasLiked"` 70 - Labels []APILabel `json:"labels,omitempty"` 71 - EditedAt *time.Time `json:"editedAt,omitempty"` 54 + ID string `json:"id"` 55 + CID string `json:"cid,omitempty"` 56 + Type string `json:"type"` 57 + Motivation string `json:"motivation,omitempty"` 58 + Author domain.Author `json:"creator"` 59 + Body *APIBody `json:"body,omitempty"` 60 + Target APITarget `json:"target"` 61 + Color string `json:"color,omitempty"` 62 + Description string `json:"description,omitempty"` 63 + Tags []string `json:"tags,omitempty"` 64 + Generator *APIGenerator `json:"generator,omitempty"` 65 + CreatedAt time.Time `json:"created"` 66 + IndexedAt time.Time `json:"indexed"` 67 + LikeCount int `json:"likeCount"` 68 + ReplyCount int `json:"replyCount"` 69 + ViewerHasLiked bool `json:"viewerHasLiked"` 70 + Labels []APILabel `json:"labels,omitempty"` 71 + EditedAt *time.Time `json:"editedAt,omitempty"` 72 72 Collection *APICollection `json:"collection,omitempty"` 73 73 } 74 74 ··· 80 80 URILabels map[string][]domain.ContentLabel 81 81 DIDLabels map[string][]domain.ContentLabel 82 82 EditTimes map[string]time.Time 83 + Collections map[string]*APICollection 83 84 } 84 85 85 86 type HydrationService struct { 86 - engagement domain.EngagementRepository 87 - profiles domain.ProfileRepository 87 + engagement domain.EngagementRepository 88 + profiles domain.ProfileRepository 89 + collections domain.CollectionRepository 88 90 } 89 91 90 92 func NewHydrationService( 91 93 engagement domain.EngagementRepository, 92 94 profiles domain.ProfileRepository, 95 + collections domain.CollectionRepository, 93 96 ) *HydrationService { 94 97 return &HydrationService{ 95 - engagement: engagement, 96 - profiles: profiles, 98 + engagement: engagement, 99 + profiles: profiles, 100 + collections: collections, 97 101 } 98 102 } 99 103 ··· 106 110 URILabels: make(map[string][]domain.ContentLabel), 107 111 DIDLabels: make(map[string][]domain.ContentLabel), 108 112 EditTimes: make(map[string]time.Time), 113 + Collections: make(map[string]*APICollection), 109 114 } 110 115 if len(notes) == 0 { 111 116 return lc, nil
+9 -9
backend/internal/slingshot/client.go
··· 40 40 } 41 41 42 42 type Identity struct { 43 - DID string `json:"did"` 44 - Handle string `json:"handle"` 45 - PDS string `json:"pds"` 43 + DID string `json:"did"` 44 + Handle string `json:"handle"` 45 + PDS string `json:"pds"` 46 46 SigningKey string `json:"signing_key"` 47 47 } 48 48 ··· 58 58 } 59 59 60 60 type HydratePayload struct { 61 - XRPC string `json:"xrpc"` 62 - AtprotoProxy string `json:"atproto_proxy"` 63 - Authorization string `json:"authorization,omitempty"` 64 - AtprotoAcceptLabelers string `json:"atproto_accept_labelers,omitempty"` 65 - Params any `json:"params,omitempty"` 66 - HydrationSources []HydrationSource `json:"hydration_sources"` 61 + XRPC string `json:"xrpc"` 62 + AtprotoProxy string `json:"atproto_proxy"` 63 + Authorization string `json:"authorization,omitempty"` 64 + AtprotoAcceptLabelers string `json:"atproto_accept_labelers,omitempty"` 65 + Params any `json:"params,omitempty"` 66 + HydrationSources []HydrationSource `json:"hydration_sources"` 67 67 } 68 68 69 69 type HydrationResult struct {
+5 -10
web/src/api/client.ts
··· 557 557 labels?: string[], 558 558 ): Promise<boolean> { 559 559 try { 560 - const res = await apiRequest( 561 - `/api/notes?uri=${encodeURIComponent(uri)}`, 562 - { 563 - method: "PUT", 564 - body: JSON.stringify({ text, tags, labels }), 565 - }, 566 - ); 560 + const res = await apiRequest(`/api/notes?uri=${encodeURIComponent(uri)}`, { 561 + method: "PUT", 562 + body: JSON.stringify({ text, tags, labels }), 563 + }); 567 564 return res.ok; 568 565 } catch (e) { 569 566 console.error("Failed to update annotation:", e); ··· 1026 1023 uri: string, 1027 1024 ): Promise<AnnotationItem | null> { 1028 1025 try { 1029 - const res = await apiRequest( 1030 - `/api/note?uri=${encodeURIComponent(uri)}`, 1031 - ); 1026 + const res = await apiRequest(`/api/note?uri=${encodeURIComponent(uri)}`); 1032 1027 if (!res.ok) return null; 1033 1028 return normalizeItem(await res.json()); 1034 1029 } catch {
+58 -55
web/src/components/common/Card.tsx
··· 107 107 onDelete?: (uri: string) => void; 108 108 onUpdate?: (item: AnnotationItem) => void; 109 109 hideShare?: boolean; 110 + hideCollection?: boolean; 110 111 layout?: "list" | "mosaic"; 111 112 } 112 113 ··· 115 116 onDelete, 116 117 onUpdate, 117 118 hideShare, 119 + hideCollection = false, 118 120 layout = "list", 119 121 }: CardProps) { 120 122 const [item, setItem] = useState(initialItem); ··· 355 357 356 358 return ( 357 359 <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative overflow-visible"> 358 - {(item.collection || (item.context && item.context.length > 0)) && ( 359 - <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> 360 - {item.addedBy && item.addedBy.did !== item.author?.did ? ( 361 - <> 362 - <ProfileHoverCard did={item.addedBy.did}> 363 - <a 364 - href={`/profile/${item.addedBy.did}`} 365 - className="flex items-center gap-1.5 font-medium hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 366 - > 367 - <Avatar 368 - did={item.addedBy.did} 369 - avatar={item.addedBy.avatar} 370 - size="xs" 371 - /> 372 - <span> 373 - {item.addedBy.displayName || `@${item.addedBy.handle}`} 374 - </span> 375 - </a> 376 - </ProfileHoverCard> 377 - <span>added to</span> 378 - </> 379 - ) : ( 380 - <span>Added to</span> 381 - )} 360 + {!hideCollection && 361 + (item.collection || (item.context && item.context.length > 0)) && ( 362 + <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> 363 + {item.addedBy && item.addedBy.did !== item.author?.did ? ( 364 + <> 365 + <ProfileHoverCard did={item.addedBy.did}> 366 + <a 367 + href={`/profile/${item.addedBy.did}`} 368 + className="flex items-center gap-1.5 font-medium hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 369 + > 370 + <Avatar 371 + did={item.addedBy.did} 372 + avatar={item.addedBy.avatar} 373 + size="xs" 374 + /> 375 + <span> 376 + {item.addedBy.displayName || `@${item.addedBy.handle}`} 377 + </span> 378 + </a> 379 + </ProfileHoverCard> 380 + <span>added to</span> 381 + </> 382 + ) : ( 383 + <span>Added to</span> 384 + )} 382 385 383 - {item.context && item.context.length > 0 ? ( 384 - item.context.map((col, index) => ( 385 - <React.Fragment key={col.uri}> 386 - {index > 0 && index < item.context!.length - 1 && ( 387 - <span className="text-surface-300 dark:text-surface-600"> 388 - , 389 - </span> 390 - )} 391 - {index > 0 && index === item.context!.length - 1 && ( 392 - <span>and</span> 393 - )} 394 - <a 395 - href={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} 396 - className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 397 - > 398 - <CollectionIcon icon={col.icon} size={14} /> 399 - <span className="font-medium">{col.name}</span> 400 - </a> 401 - </React.Fragment> 402 - )) 403 - ) : ( 404 - <a 405 - href={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`} 406 - className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 407 - > 408 - <CollectionIcon icon={item.collection!.icon} size={14} /> 409 - <span className="font-medium">{item.collection!.name}</span> 410 - </a> 411 - )} 412 - </div> 413 - )} 386 + {item.context && item.context.length > 0 ? ( 387 + item.context.map((col, index) => ( 388 + <React.Fragment key={col.uri}> 389 + {index > 0 && index < item.context!.length - 1 && ( 390 + <span className="text-surface-300 dark:text-surface-600"> 391 + , 392 + </span> 393 + )} 394 + {index > 0 && index === item.context!.length - 1 && ( 395 + <span>and</span> 396 + )} 397 + <a 398 + href={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} 399 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 400 + > 401 + <CollectionIcon icon={col.icon} size={14} /> 402 + <span className="font-medium">{col.name}</span> 403 + </a> 404 + </React.Fragment> 405 + )) 406 + ) : ( 407 + <a 408 + href={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`} 409 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 410 + > 411 + <CollectionIcon icon={item.collection!.icon} size={14} /> 412 + <span className="font-medium">{item.collection!.name}</span> 413 + </a> 414 + )} 415 + </div> 416 + )} 414 417 415 418 <div className="flex items-start gap-3"> 416 419 <ProfileHoverCard did={item.author?.did}>
+1 -1
web/src/views/collections/CollectionDetail.tsx
··· 244 244 ) : ( 245 245 items.map((item) => ( 246 246 <div key={item.uri} className="relative group"> 247 - <Card item={item} hideShare /> 247 + <Card item={item} hideShare hideCollection /> 248 248 {isOwner && !isSemble && item.collectionItemUri && ( 249 249 <button 250 250 className="absolute top-3 right-3 p-1.5 bg-white/90 dark:bg-surface-800/90 backdrop-blur text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 rounded-lg shadow-sm transition-all"