tangled
alpha
login
or
join now
margin.at
/
margin
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
76
fork
atom
overview
issues
2
pulls
pipelines
Implement Semble cards and collections to Margin
scanash.com
1 week ago
453c5db8
87cca4da
+1396
-268
17 changed files
expand all
collapse all
unified
split
backend
internal
api
collections.go
handler.go
hydration.go
semble_fetch.go
firehose
ingester.go
sync
service.go
xrpc
semble.go
web
public
semble-logo.svg
src
api
client.js
components
AnnotationCard.jsx
BookmarkCard.jsx
CollectionIcon.jsx
CollectionRow.jsx
ShareMenu.jsx
css
feed.css
pages
CollectionDetail.jsx
Feed.jsx
+71
-43
backend/internal/api/collections.go
···
1
package api
2
3
import (
0
4
"encoding/json"
0
5
"log"
6
"net/http"
7
"net/url"
···
286
return
287
}
288
289
-
enrichedItems := make([]EnrichedCollectionItem, 0, len(items))
0
0
0
0
0
0
0
0
0
0
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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{}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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 (
0
4
"encoding/json"
0
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)
0
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")
0
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
0
0
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)
0
0
0
0
0
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)
0
0
0
0
0
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)
0
0
0
0
0
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)
0
0
0
0
0
0
0
0
181
}
182
}
183
···
185
authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID)
186
authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID)
187
0
0
0
0
0
0
0
0
0
0
0
0
0
0
188
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID)
189
190
var feed []interface{}
···
201
feed = append(feed, ci)
202
}
203
204
-
sortFeed(feed)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
205
206
if len(feed) > limit {
207
feed = feed[:limit]
···
288
h.db.CreateBookmark(&b)
289
}
290
}()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
291
292
authAnnos, _ := hydrateAnnotations(h.db, annotations, did)
293
authHighs, _ := hydrateHighlights(h.db, highlights, did)
294
authBooks, _ := hydrateBookmarks(h.db, bookmarks, did)
0
295
296
var feed []interface{}
297
for _, a := range authAnnos {
···
302
}
303
for _, b := range authBooks {
304
feed = append(feed, b)
0
0
0
305
}
306
307
sortFeed(feed)
···
363
}
364
}
365
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
}
0
0
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
}
0
0
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
}
0
0
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)
0
0
0
708
}
709
+
}()
0
0
0
0
0
0
0
0
0
0
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)
0
0
0
748
}
749
+
}()
0
0
0
0
0
0
0
0
0
0
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)
0
0
0
788
}
789
+
}()
0
0
0
0
0
0
0
0
0
0
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)
0
0
0
552
}
553
}
554
···
633
apiItem.Highlight = &val
634
} else if val, ok := bookmarksMap[item.AnnotationURI]; ok {
635
apiItem.Bookmark = &val
0
0
0
0
0
0
0
0
0
0
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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"
0
0
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)
0
0
0
55
56
return i
57
}
···
235
i.db.RemoveFromCollection(uri)
236
case CollectionProfile:
237
i.db.DeleteProfile(uri)
0
0
0
0
0
0
0
0
238
}
239
}
240
···
687
log.Printf("Indexed profile from %s", event.Repo)
688
}
689
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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"
0
9
"time"
10
11
"margin.at/internal/db"
···
29
xrpc.CollectionLike,
30
xrpc.CollectionCollection,
31
xrpc.CollectionCollectionItem,
0
0
0
32
}
33
34
results := make(map[string]string)
···
101
switch collectionNSID {
102
case xrpc.CollectionAnnotation:
103
localURIs, err = s.db.GetAnnotationURIs(did)
0
104
case xrpc.CollectionHighlight:
105
localURIs, err = s.db.GetHighlightURIs(did)
0
106
case xrpc.CollectionBookmark:
107
localURIs, err = s.db.GetBookmarkURIs(did)
0
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
}
0
114
} else {
115
err = e
116
}
···
120
for _, item := range items {
121
localURIs = append(localURIs, item.URI)
122
}
0
123
} else {
124
err = e
125
}
···
129
for _, r := range replies {
130
localURIs = append(localURIs, r.URI)
131
}
0
132
} else {
133
err = e
134
}
···
138
for _, l := range likes {
139
localURIs = append(localURIs, l.URI)
140
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
141
} else {
142
err = e
143
}
···
161
_ = s.db.DeleteReply(uri)
162
case xrpc.CollectionLike:
163
_ = s.db.DeleteLike(uri)
0
0
0
0
0
0
0
164
}
165
deletedCount++
166
}
···
173
}
174
}
175
return results, nil
0
0
0
0
0
0
0
0
0
0
0
0
0
0
176
}
177
178
func strPtr(s string) *string {
···
422
SubjectURI: record.Subject.URI,
423
CreatedAt: createdAt,
424
IndexedAt: time.Now(),
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
···
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 = "",
0
0
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)}`;
0
0
35
return request(url);
36
}
37
···
135
let url = `${API_BASE}/collections`;
136
if (did) url += `?author=${encodeURIComponent(did)}`;
137
return request(url);
0
0
0
0
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" }}>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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"
0
410
/>
411
<button
412
className="annotation-action"
···
557
</div>
558
559
<div className="annotation-header-right">
560
-
<div style={{ display: "flex", gap: "4px" }}>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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"
0
0
0
0
0
115
>
116
-
<TrashIcon size={16} />
117
-
</button>
0
0
0
0
0
118
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
119
</div>
120
</div>
121
</header>
···
164
text={data.title || data.description}
165
handle={data.author?.handle}
166
type="Bookmark"
0
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
0
0
0
0
0
0
0
0
0
0
0
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];
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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) => (
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
228
<button
229
-
key={fork.domain}
230
className="share-menu-item"
231
-
onClick={() => handleShareToFork(fork.domain)}
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
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
0
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>
0
0
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
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
0
0
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);
0
0
0
0
0
40
41
-
let targetUri = null;
42
-
let targetDid = paramAuthorDid || user?.did;
0
43
44
-
if (handle && rkey) {
45
-
try {
46
-
targetDid = await resolveHandle(handle);
0
0
0
0
0
0
0
0
0
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");
0
0
0
0
0
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;
0
0
0
0
0
0
0
0
0
0
0
0
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]);
0
0
0
0
0
0
0
0
0
0
0
0
0
104
105
const handleEditSuccess = () => {
106
-
fetchContext();
107
setIsEditModalOpen(false);
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
-
)}
0
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}`;
0
0
65
}
0
0
0
0
0
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
}
0
0
0
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;
0
0
85
86
+
setCollection(collectionData);
0
0
0
87
88
+
const itemsData = await getCollectionItems(collectionData.uri);
89
+
if (!active) return;
0
0
0
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
+
};
0
0
0
0
0
0
0
0
108
0
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 = () => {
0
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
0
0
0
0
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
0
0
0
0
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,
0
0
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
-
});
0
0
0
0
0
0
80
81
return (
82
<div className="feed-page">
···
117
</div>
118
119
{}
120
-
<div className="feed-filters">
0
0
0
0
0
0
121
<button
122
-
className={`filter-tab ${filter === "all" ? "active" : ""}`}
123
-
onClick={() => setFilter("all")}
124
>
125
All
126
</button>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
127
{user && (
128
<button
129
-
className={`filter-tab ${filter === "my-tags" ? "active" : ""}`}
130
-
onClick={() => setFilter("my-tags")}
131
>
132
My Feed
133
</button>
134
)}
0
0
0
135
<button
136
-
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
0
0
0
0
0
0
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