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