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
36
fork
atom
overview
issues
2
pulls
pipelines
fix various bugs
scanash.com
1 week ago
db36e33e
1a5da2ed
+225
-148
10 changed files
expand all
collapse all
unified
split
backend
internal
api
annotations.go
collections.go
handler.go
hydration.go
db
queries.go
web
src
api
client.js
components
AnnotationCard.jsx
pages
Feed.jsx
New.jsx
Profile.jsx
+28
-21
backend/internal/api/annotations.go
···
72
}
73
74
record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation)
0
0
0
75
76
var result *xrpc.CreateRecordOutput
77
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
98
selectorJSONPtr = &selectorStr
99
}
100
0
0
0
0
0
0
0
101
cid := result.CID
102
did := session.DID
103
annotation := &db.Annotation{
···
110
TargetHash: urlHash,
111
TargetTitle: targetTitlePtr,
112
SelectorJSON: selectorJSONPtr,
0
113
CreatedAt: time.Now(),
114
IndexedAt: time.Now(),
115
}
···
208
}
209
rkey := parts[2]
210
211
-
var selector interface{} = nil
212
-
if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" {
213
-
json.Unmarshal([]byte(*annotation.SelectorJSON), &selector)
214
-
}
215
-
216
tagsJSON := ""
217
if len(req.Tags) > 0 {
218
tagsBytes, _ := json.Marshal(req.Tags)
219
tagsJSON = string(tagsBytes)
220
}
221
222
-
record := map[string]interface{}{
223
-
"$type": xrpc.CollectionAnnotation,
224
-
"text": req.Text,
225
-
"url": annotation.TargetSource,
226
-
"createdAt": annotation.CreatedAt.Format(time.RFC3339),
227
-
}
228
-
if selector != nil {
229
-
record["selector"] = selector
230
-
}
231
-
if len(req.Tags) > 0 {
232
-
record["tags"] = req.Tags
233
-
}
234
-
if annotation.TargetTitle != nil {
235
-
record["title"] = *annotation.TargetTitle
236
-
}
237
-
238
if annotation.BodyValue != nil {
239
previousContent := *annotation.BodyValue
240
s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID)
···
242
243
var result *xrpc.PutRecordOutput
244
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
245
var updateErr error
246
result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
247
if updateErr != nil {
···
72
}
73
74
record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation)
75
+
if len(req.Tags) > 0 {
76
+
record.Tags = req.Tags
77
+
}
78
79
var result *xrpc.CreateRecordOutput
80
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
101
selectorJSONPtr = &selectorStr
102
}
103
104
+
var tagsJSONPtr *string
105
+
if len(req.Tags) > 0 {
106
+
tagsBytes, _ := json.Marshal(req.Tags)
107
+
tagsStr := string(tagsBytes)
108
+
tagsJSONPtr = &tagsStr
109
+
}
110
+
111
cid := result.CID
112
did := session.DID
113
annotation := &db.Annotation{
···
120
TargetHash: urlHash,
121
TargetTitle: targetTitlePtr,
122
SelectorJSON: selectorJSONPtr,
123
+
TagsJSON: tagsJSONPtr,
124
CreatedAt: time.Now(),
125
IndexedAt: time.Now(),
126
}
···
219
}
220
rkey := parts[2]
221
0
0
0
0
0
222
tagsJSON := ""
223
if len(req.Tags) > 0 {
224
tagsBytes, _ := json.Marshal(req.Tags)
225
tagsJSON = string(tagsBytes)
226
}
227
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
228
if annotation.BodyValue != nil {
229
previousContent := *annotation.BodyValue
230
s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID)
···
232
233
var result *xrpc.PutRecordOutput
234
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
235
+
existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey)
236
+
if getErr != nil {
237
+
return fmt.Errorf("failed to fetch existing record: %w", getErr)
238
+
}
239
+
240
+
var record map[string]interface{}
241
+
if err := json.Unmarshal(existing.Value, &record); err != nil {
242
+
return fmt.Errorf("failed to parse existing record: %w", err)
243
+
}
244
+
245
+
record["text"] = req.Text
246
+
if req.Tags != nil {
247
+
record["tags"] = req.Tags
248
+
} else {
249
+
delete(record, "tags")
250
+
}
251
+
252
var updateErr error
253
result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
254
if updateErr != nil {
+9
-3
backend/internal/api/collections.go
···
278
279
enrichedItems := make([]EnrichedCollectionItem, 0, len(items))
280
0
0
0
0
0
0
281
for _, item := range items {
282
enriched := EnrichedCollectionItem{
283
URI: item.URI,
···
290
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
291
enriched.Type = "annotation"
292
if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil {
293
-
hydrated, _ := hydrateAnnotations([]db.Annotation{*a})
294
if len(hydrated) > 0 {
295
enriched.Annotation = &hydrated[0]
296
}
···
298
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
299
enriched.Type = "highlight"
300
if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil {
301
-
hydrated, _ := hydrateHighlights([]db.Highlight{*h})
302
if len(hydrated) > 0 {
303
enriched.Highlight = &hydrated[0]
304
}
···
306
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
307
enriched.Type = "bookmark"
308
if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil {
309
-
hydrated, _ := hydrateBookmarks([]db.Bookmark{*b})
310
if len(hydrated) > 0 {
311
enriched.Bookmark = &hydrated[0]
312
}
···
278
279
enrichedItems := make([]EnrichedCollectionItem, 0, len(items))
280
281
+
session, err := s.refresher.GetSessionWithAutoRefresh(r)
282
+
viewerDID := ""
283
+
if err == nil {
284
+
viewerDID = session.DID
285
+
}
286
+
287
for _, item := range items {
288
enriched := EnrichedCollectionItem{
289
URI: item.URI,
···
296
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
297
enriched.Type = "annotation"
298
if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil {
299
+
hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID)
300
if len(hydrated) > 0 {
301
enriched.Annotation = &hydrated[0]
302
}
···
304
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
305
enriched.Type = "highlight"
306
if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil {
307
+
hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID)
308
if len(hydrated) > 0 {
309
enriched.Highlight = &hydrated[0]
310
}
···
312
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
313
enriched.Type = "bookmark"
314
if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil {
315
+
hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID)
316
if len(hydrated) > 0 {
317
enriched.Bookmark = &hydrated[0]
318
}
+34
-17
backend/internal/api/handler.go
···
102
return
103
}
104
105
-
enriched, _ := hydrateAnnotations(annotations)
106
107
w.Header().Set("Content-Type", "application/json")
108
json.NewEncoder(w).Encode(map[string]interface{}{
···
136
bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0)
137
collectionItems = []db.CollectionItem{}
138
}
0
0
0
0
0
139
} else {
140
annotations, _ = h.db.GetRecentAnnotations(limit, 0)
141
highlights, _ = h.db.GetRecentHighlights(limit, 0)
···
146
}
147
}
148
149
-
authAnnos, _ := hydrateAnnotations(annotations)
150
-
authHighs, _ := hydrateHighlights(highlights)
151
-
authBooks, _ := hydrateBookmarks(bookmarks)
0
152
153
-
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems)
154
155
var feed []interface{}
156
for _, a := range authAnnos {
···
222
}
223
224
if annotation, err := h.db.GetAnnotationByURI(uri); err == nil {
225
-
if enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}); len(enriched) > 0 {
226
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
227
return
228
}
229
}
230
231
if highlight, err := h.db.GetHighlightByURI(uri); err == nil {
232
-
if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 {
233
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
234
return
235
}
···
238
if strings.Contains(uri, "at.margin.annotation") {
239
highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1)
240
if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil {
241
-
if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 {
242
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
243
return
244
}
···
246
}
247
248
if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil {
249
-
if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 {
250
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
251
return
252
}
···
255
if strings.Contains(uri, "at.margin.annotation") {
256
bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1)
257
if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil {
258
-
if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 {
259
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
260
return
261
}
···
284
annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
285
highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset)
286
287
-
enrichedAnnotations, _ := hydrateAnnotations(annotations)
288
-
enrichedHighlights, _ := hydrateHighlights(highlights)
289
290
w.Header().Set("Content-Type", "application/json")
291
json.NewEncoder(w).Encode(map[string]interface{}{
···
319
return
320
}
321
322
-
enriched, _ := hydrateHighlights(highlights)
323
324
w.Header().Set("Content-Type", "application/json")
325
json.NewEncoder(w).Encode(map[string]interface{}{
···
346
return
347
}
348
349
-
enriched, _ := hydrateBookmarks(bookmarks)
350
351
w.Header().Set("Content-Type", "application/json")
352
json.NewEncoder(w).Encode(map[string]interface{}{
···
371
return
372
}
373
374
-
enriched, _ := hydrateAnnotations(annotations)
375
376
w.Header().Set("Content-Type", "application/json")
377
json.NewEncoder(w).Encode(map[string]interface{}{
···
397
return
398
}
399
400
-
enriched, _ := hydrateHighlights(highlights)
401
402
w.Header().Set("Content-Type", "application/json")
403
json.NewEncoder(w).Encode(map[string]interface{}{
···
423
return
424
}
425
426
-
enriched, _ := hydrateBookmarks(bookmarks)
427
428
w.Header().Set("Content-Type", "application/json")
429
json.NewEncoder(w).Encode(map[string]interface{}{
···
622
w.Header().Set("Content-Type", "application/json")
623
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
624
}
0
0
0
0
0
0
0
0
0
0
0
···
102
return
103
}
104
105
+
enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
106
107
w.Header().Set("Content-Type", "application/json")
108
json.NewEncoder(w).Encode(map[string]interface{}{
···
136
bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0)
137
collectionItems = []db.CollectionItem{}
138
}
139
+
} else if creator != "" {
140
+
annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0)
141
+
highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0)
142
+
bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0)
143
+
collectionItems = []db.CollectionItem{}
144
} else {
145
annotations, _ = h.db.GetRecentAnnotations(limit, 0)
146
highlights, _ = h.db.GetRecentHighlights(limit, 0)
···
151
}
152
}
153
154
+
viewerDID := h.getViewerDID(r)
155
+
authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID)
156
+
authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID)
157
+
authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID)
158
159
+
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID)
160
161
var feed []interface{}
162
for _, a := range authAnnos {
···
228
}
229
230
if annotation, err := h.db.GetAnnotationByURI(uri); err == nil {
231
+
if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 {
232
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
233
return
234
}
235
}
236
237
if highlight, err := h.db.GetHighlightByURI(uri); err == nil {
238
+
if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 {
239
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
240
return
241
}
···
244
if strings.Contains(uri, "at.margin.annotation") {
245
highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1)
246
if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil {
247
+
if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 {
248
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
249
return
250
}
···
252
}
253
254
if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil {
255
+
if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 {
256
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
257
return
258
}
···
261
if strings.Contains(uri, "at.margin.annotation") {
262
bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1)
263
if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil {
264
+
if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 {
265
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
266
return
267
}
···
290
annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
291
highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset)
292
293
+
enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
294
+
enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
295
296
w.Header().Set("Content-Type", "application/json")
297
json.NewEncoder(w).Encode(map[string]interface{}{
···
325
return
326
}
327
328
+
enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
329
330
w.Header().Set("Content-Type", "application/json")
331
json.NewEncoder(w).Encode(map[string]interface{}{
···
352
return
353
}
354
355
+
enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
356
357
w.Header().Set("Content-Type", "application/json")
358
json.NewEncoder(w).Encode(map[string]interface{}{
···
377
return
378
}
379
380
+
enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
381
382
w.Header().Set("Content-Type", "application/json")
383
json.NewEncoder(w).Encode(map[string]interface{}{
···
403
return
404
}
405
406
+
enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
407
408
w.Header().Set("Content-Type", "application/json")
409
json.NewEncoder(w).Encode(map[string]interface{}{
···
429
return
430
}
431
432
+
enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
433
434
w.Header().Set("Content-Type", "application/json")
435
json.NewEncoder(w).Encode(map[string]interface{}{
···
628
w.Header().Set("Content-Type", "application/json")
629
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
630
}
631
+
func (h *Handler) getViewerDID(r *http.Request) string {
632
+
cookie, err := r.Cookie("margin_session")
633
+
if err != nil {
634
+
return ""
635
+
}
636
+
did, _, _, _, _, err := h.db.GetSession(cookie.Value)
637
+
if err != nil {
638
+
return ""
639
+
}
640
+
return did
641
+
}
+73
-36
backend/internal/api/hydration.go
···
50
}
51
52
type APIAnnotation struct {
53
-
ID string `json:"id"`
54
-
CID string `json:"cid"`
55
-
Type string `json:"type"`
56
-
Motivation string `json:"motivation,omitempty"`
57
-
Author Author `json:"creator"`
58
-
Body *APIBody `json:"body,omitempty"`
59
-
Target APITarget `json:"target"`
60
-
Tags []string `json:"tags,omitempty"`
61
-
Generator *APIGenerator `json:"generator,omitempty"`
62
-
CreatedAt time.Time `json:"created"`
63
-
IndexedAt time.Time `json:"indexed"`
0
0
0
64
}
65
66
type APIHighlight struct {
67
-
ID string `json:"id"`
68
-
Type string `json:"type"`
69
-
Author Author `json:"creator"`
70
-
Target APITarget `json:"target"`
71
-
Color string `json:"color,omitempty"`
72
-
Tags []string `json:"tags,omitempty"`
73
-
CreatedAt time.Time `json:"created"`
74
-
CID string `json:"cid,omitempty"`
0
0
0
75
}
76
77
type APIBookmark struct {
78
-
ID string `json:"id"`
79
-
Type string `json:"type"`
80
-
Author Author `json:"creator"`
81
-
Source string `json:"source"`
82
-
Title string `json:"title,omitempty"`
83
-
Description string `json:"description,omitempty"`
84
-
Tags []string `json:"tags,omitempty"`
85
-
CreatedAt time.Time `json:"created"`
86
-
CID string `json:"cid,omitempty"`
0
0
0
87
}
88
89
type APIReply struct {
···
132
ReadAt *time.Time `json:"readAt,omitempty"`
133
}
134
135
-
func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) {
136
if len(annotations) == 0 {
137
return []APIAnnotation{}, nil
138
}
···
197
CreatedAt: a.CreatedAt,
198
IndexedAt: a.IndexedAt,
199
}
0
0
0
0
0
0
0
0
0
0
200
}
201
202
return result, nil
203
}
204
205
-
func hydrateHighlights(highlights []db.Highlight) ([]APIHighlight, error) {
206
if len(highlights) == 0 {
207
return []APIHighlight{}, nil
208
}
···
251
CreatedAt: h.CreatedAt,
252
CID: cid,
253
}
0
0
0
0
0
0
0
0
0
0
254
}
255
256
return result, nil
257
}
258
259
-
func hydrateBookmarks(bookmarks []db.Bookmark) ([]APIBookmark, error) {
260
if len(bookmarks) == 0 {
261
return []APIBookmark{}, nil
262
}
···
295
Tags: tags,
296
CreatedAt: b.CreatedAt,
297
CID: cid,
0
0
0
0
0
0
0
0
0
298
}
299
}
300
···
439
return result, nil
440
}
441
442
-
func hydrateCollectionItems(database *db.DB, items []db.CollectionItem) ([]APICollectionItem, error) {
443
if len(items) == 0 {
444
return []APICollectionItem{}, nil
445
}
···
479
480
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
481
if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil {
482
-
hydrated, _ := hydrateAnnotations([]db.Annotation{*a})
483
if len(hydrated) > 0 {
484
apiItem.Annotation = &hydrated[0]
485
}
486
}
487
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
488
if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil {
489
-
hydrated, _ := hydrateHighlights([]db.Highlight{*h})
490
if len(hydrated) > 0 {
491
apiItem.Highlight = &hydrated[0]
492
}
493
}
494
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
495
if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil {
496
-
hydrated, _ := hydrateBookmarks([]db.Bookmark{*b})
497
if len(hydrated) > 0 {
498
apiItem.Bookmark = &hydrated[0]
499
} else {
500
log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI)
501
}
502
} else {
503
-
log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err)
504
}
505
} else {
506
log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI)
···
50
}
51
52
type APIAnnotation struct {
53
+
ID string `json:"id"`
54
+
CID string `json:"cid"`
55
+
Type string `json:"type"`
56
+
Motivation string `json:"motivation,omitempty"`
57
+
Author Author `json:"creator"`
58
+
Body *APIBody `json:"body,omitempty"`
59
+
Target APITarget `json:"target"`
60
+
Tags []string `json:"tags,omitempty"`
61
+
Generator *APIGenerator `json:"generator,omitempty"`
62
+
CreatedAt time.Time `json:"created"`
63
+
IndexedAt time.Time `json:"indexed"`
64
+
LikeCount int `json:"likeCount"`
65
+
ReplyCount int `json:"replyCount"`
66
+
ViewerHasLiked bool `json:"viewerHasLiked"`
67
}
68
69
type APIHighlight struct {
70
+
ID string `json:"id"`
71
+
Type string `json:"type"`
72
+
Author Author `json:"creator"`
73
+
Target APITarget `json:"target"`
74
+
Color string `json:"color,omitempty"`
75
+
Tags []string `json:"tags,omitempty"`
76
+
CreatedAt time.Time `json:"created"`
77
+
CID string `json:"cid,omitempty"`
78
+
LikeCount int `json:"likeCount"`
79
+
ReplyCount int `json:"replyCount"`
80
+
ViewerHasLiked bool `json:"viewerHasLiked"`
81
}
82
83
type APIBookmark struct {
84
+
ID string `json:"id"`
85
+
Type string `json:"type"`
86
+
Author Author `json:"creator"`
87
+
Source string `json:"source"`
88
+
Title string `json:"title,omitempty"`
89
+
Description string `json:"description,omitempty"`
90
+
Tags []string `json:"tags,omitempty"`
91
+
CreatedAt time.Time `json:"created"`
92
+
CID string `json:"cid,omitempty"`
93
+
LikeCount int `json:"likeCount"`
94
+
ReplyCount int `json:"replyCount"`
95
+
ViewerHasLiked bool `json:"viewerHasLiked"`
96
}
97
98
type APIReply struct {
···
141
ReadAt *time.Time `json:"readAt,omitempty"`
142
}
143
144
+
func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) {
145
if len(annotations) == 0 {
146
return []APIAnnotation{}, nil
147
}
···
206
CreatedAt: a.CreatedAt,
207
IndexedAt: a.IndexedAt,
208
}
209
+
210
+
if database != nil {
211
+
result[i].LikeCount, _ = database.GetLikeCount(a.URI)
212
+
result[i].ReplyCount, _ = database.GetReplyCount(a.URI)
213
+
if viewerDID != "" {
214
+
if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil {
215
+
result[i].ViewerHasLiked = true
216
+
}
217
+
}
218
+
}
219
}
220
221
return result, nil
222
}
223
224
+
func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID string) ([]APIHighlight, error) {
225
if len(highlights) == 0 {
226
return []APIHighlight{}, nil
227
}
···
270
CreatedAt: h.CreatedAt,
271
CID: cid,
272
}
273
+
274
+
if database != nil {
275
+
result[i].LikeCount, _ = database.GetLikeCount(h.URI)
276
+
result[i].ReplyCount, _ = database.GetReplyCount(h.URI)
277
+
if viewerDID != "" {
278
+
if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil {
279
+
result[i].ViewerHasLiked = true
280
+
}
281
+
}
282
+
}
283
}
284
285
return result, nil
286
}
287
288
+
func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string) ([]APIBookmark, error) {
289
if len(bookmarks) == 0 {
290
return []APIBookmark{}, nil
291
}
···
324
Tags: tags,
325
CreatedAt: b.CreatedAt,
326
CID: cid,
327
+
}
328
+
if database != nil {
329
+
result[i].LikeCount, _ = database.GetLikeCount(b.URI)
330
+
result[i].ReplyCount, _ = database.GetReplyCount(b.URI)
331
+
if viewerDID != "" {
332
+
if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil {
333
+
result[i].ViewerHasLiked = true
334
+
}
335
+
}
336
}
337
}
338
···
477
return result, nil
478
}
479
480
+
func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDID string) ([]APICollectionItem, error) {
481
if len(items) == 0 {
482
return []APICollectionItem{}, nil
483
}
···
517
518
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
519
if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil {
520
+
hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID)
521
if len(hydrated) > 0 {
522
apiItem.Annotation = &hydrated[0]
523
}
524
}
525
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
526
if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil {
527
+
hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID)
528
if len(hydrated) > 0 {
529
apiItem.Highlight = &hydrated[0]
530
}
531
}
532
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
533
if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil {
534
+
hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID)
535
if len(hydrated) > 0 {
536
apiItem.Bookmark = &hydrated[0]
537
} else {
538
log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI)
539
}
540
} else {
0
541
}
542
} else {
543
log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI)
+6
backend/internal/db/queries.go
···
634
return count, err
635
}
636
0
0
0
0
0
0
637
func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) {
638
var like Like
639
err := db.QueryRow(db.Rebind(`
···
634
return count, err
635
}
636
637
+
func (db *DB) GetReplyCount(rootURI string) (int, error) {
638
+
var count int
639
+
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count)
640
+
return count, err
641
+
}
642
+
643
func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) {
644
var like Like
645
err := db.QueryRow(db.Rebind(`
+18
web/src/api/client.js
···
314
tags: item.tags || [],
315
createdAt: item.createdAt || item.created,
316
cid: item.cid || item.CID,
0
0
0
317
};
318
}
319
···
328
tags: item.tags || [],
329
createdAt: item.createdAt || item.created,
330
cid: item.cid || item.CID,
0
0
0
331
};
332
}
333
···
343
tags: item.tags || [],
344
createdAt: item.createdAt || item.created,
345
cid: item.cid || item.CID,
0
0
0
346
};
347
}
348
···
358
tags: item.tags || [],
359
createdAt: item.createdAt || item.created,
360
cid: item.cid || item.CID,
0
0
0
361
};
362
}
363
···
371
color: highlight.color,
372
tags: highlight.tags || [],
373
createdAt: highlight.createdAt || highlight.created,
0
0
0
374
};
375
}
376
···
383
description: bookmark.description,
384
tags: bookmark.tags || [],
385
createdAt: bookmark.createdAt || bookmark.created,
0
0
0
386
};
387
}
388
···
314
tags: item.tags || [],
315
createdAt: item.createdAt || item.created,
316
cid: item.cid || item.CID,
317
+
likeCount: item.likeCount || 0,
318
+
replyCount: item.replyCount || 0,
319
+
viewerHasLiked: item.viewerHasLiked || false,
320
};
321
}
322
···
331
tags: item.tags || [],
332
createdAt: item.createdAt || item.created,
333
cid: item.cid || item.CID,
334
+
likeCount: item.likeCount || 0,
335
+
replyCount: item.replyCount || 0,
336
+
viewerHasLiked: item.viewerHasLiked || false,
337
};
338
}
339
···
349
tags: item.tags || [],
350
createdAt: item.createdAt || item.created,
351
cid: item.cid || item.CID,
352
+
likeCount: item.likeCount || 0,
353
+
replyCount: item.replyCount || 0,
354
+
viewerHasLiked: item.viewerHasLiked || false,
355
};
356
}
357
···
367
tags: item.tags || [],
368
createdAt: item.createdAt || item.created,
369
cid: item.cid || item.CID,
370
+
likeCount: item.likeCount || 0,
371
+
replyCount: item.replyCount || 0,
372
+
viewerHasLiked: item.viewerHasLiked || false,
373
};
374
}
375
···
383
color: highlight.color,
384
tags: highlight.tags || [],
385
createdAt: highlight.createdAt || highlight.created,
386
+
likeCount: highlight.likeCount || 0,
387
+
replyCount: highlight.replyCount || 0,
388
+
viewerHasLiked: highlight.viewerHasLiked || false,
389
};
390
}
391
···
398
description: bookmark.description,
399
tags: bookmark.tags || [],
400
createdAt: bookmark.createdAt || bookmark.created,
401
+
likeCount: bookmark.likeCount || 0,
402
+
replyCount: bookmark.replyCount || 0,
403
+
viewerHasLiked: bookmark.viewerHasLiked || false,
404
};
405
}
406
+16
-44
web/src/components/AnnotationCard.jsx
···
67
const { user, login } = useAuth();
68
const data = normalizeAnnotation(annotation);
69
70
-
const [likeCount, setLikeCount] = useState(0);
71
-
const [isLiked, setIsLiked] = useState(false);
72
const [deleting, setDeleting] = useState(false);
73
const [isEditing, setIsEditing] = useState(false);
74
const [editText, setEditText] = useState(data.text || "");
···
80
const [loadingHistory, setLoadingHistory] = useState(false);
81
82
const [replies, setReplies] = useState([]);
83
-
const [replyCount, setReplyCount] = useState(0);
84
const [showReplies, setShowReplies] = useState(false);
85
const [replyingTo, setReplyingTo] = useState(null);
86
const [replyText, setReplyText] = useState("");
···
90
91
const [hasEditHistory, setHasEditHistory] = useState(false);
92
93
-
useEffect(() => {
94
-
let mounted = true;
95
-
async function fetchData() {
96
-
try {
97
-
const repliesRes = await getReplies(data.uri);
98
-
if (mounted && repliesRes.items) {
99
-
setReplies(repliesRes.items);
100
-
setReplyCount(repliesRes.items.length);
101
-
}
102
-
103
-
const likeRes = await getLikeCount(data.uri);
104
-
if (mounted) {
105
-
if (likeRes.count !== undefined) {
106
-
setLikeCount(likeRes.count);
107
-
}
108
-
if (likeRes.liked !== undefined) {
109
-
setIsLiked(likeRes.liked);
110
-
}
111
-
}
112
-
113
-
if (!data.color && !data.description) {
114
-
try {
115
-
const history = await getEditHistory(data.uri);
116
-
if (mounted && history && history.length > 0) {
117
-
setHasEditHistory(true);
118
-
}
119
-
} catch {}
120
-
}
121
-
} catch (err) {
122
-
console.error("Failed to fetch data:", err);
123
-
}
124
-
}
125
-
if (data.uri) {
126
-
fetchData();
127
-
}
128
-
return () => {
129
-
mounted = false;
130
-
};
131
-
}, [data.uri]);
132
133
const fetchHistory = async () => {
134
if (showHistory) {
···
421
rel="noopener noreferrer"
422
className="annotation-highlight"
423
style={{
424
-
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
425
}}
426
>
427
<mark>"{highlightedText}"</mark>
···
497
</button>
498
<button
499
className={`annotation-action ${showReplies ? "active" : ""}`}
500
-
onClick={() => setShowReplies(!showReplies)}
0
0
0
0
0
0
0
0
0
0
501
>
502
<MessageIcon size={16} />
503
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
···
67
const { user, login } = useAuth();
68
const data = normalizeAnnotation(annotation);
69
70
+
const [likeCount, setLikeCount] = useState(data.likeCount || 0);
71
+
const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false);
72
const [deleting, setDeleting] = useState(false);
73
const [isEditing, setIsEditing] = useState(false);
74
const [editText, setEditText] = useState(data.text || "");
···
80
const [loadingHistory, setLoadingHistory] = useState(false);
81
82
const [replies, setReplies] = useState([]);
83
+
const [replyCount, setReplyCount] = useState(data.replyCount || 0);
84
const [showReplies, setShowReplies] = useState(false);
85
const [replyingTo, setReplyingTo] = useState(null);
86
const [replyText, setReplyText] = useState("");
···
90
91
const [hasEditHistory, setHasEditHistory] = useState(false);
92
93
+
useEffect(() => {}, []);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
94
95
const fetchHistory = async () => {
96
if (showHistory) {
···
383
rel="noopener noreferrer"
384
className="annotation-highlight"
385
style={{
386
+
borderLeftColor: data.color || "#f59e0b",
387
}}
388
>
389
<mark>"{highlightedText}"</mark>
···
459
</button>
460
<button
461
className={`annotation-action ${showReplies ? "active" : ""}`}
462
+
onClick={async () => {
463
+
if (!showReplies && replies.length === 0) {
464
+
try {
465
+
const res = await getReplies(data.uri);
466
+
if (res.items) setReplies(res.items);
467
+
} catch (err) {
468
+
console.error("Failed to load replies:", err);
469
+
}
470
+
}
471
+
setShowReplies(!showReplies);
472
+
}}
473
>
474
<MessageIcon size={16} />
475
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
+35
-9
web/src/pages/Feed.jsx
···
12
export default function Feed() {
13
const [searchParams, setSearchParams] = useSearchParams();
14
const tagFilter = searchParams.get("tag");
0
0
15
const [annotations, setAnnotations] = useState([]);
16
const [loading, setLoading] = useState(true);
17
const [error, setError] = useState(null);
18
-
const [filter, setFilter] = useState("all");
0
0
0
0
0
0
0
0
0
0
0
19
const [collectionModalState, setCollectionModalState] = useState({
20
isOpen: false,
21
uri: null,
···
28
try {
29
setLoading(true);
30
let creatorDid = "";
31
-
if (filter === "my-tags" && user?.did) {
32
-
creatorDid = user.did;
0
0
0
0
0
0
0
33
}
34
35
const data = await getAnnotationFeed(
···
83
Filtering by tag: <strong>#{tagFilter}</strong>
84
</span>
85
<button
86
-
onClick={() => setSearchParams({})}
0
0
0
0
0
0
87
className="btn btn-sm"
88
style={{ padding: "2px 8px", fontSize: "0.8rem" }}
89
>
···
97
<div className="feed-filters">
98
<button
99
className={`filter-tab ${filter === "all" ? "active" : ""}`}
100
-
onClick={() => setFilter("all")}
101
>
102
All
103
</button>
104
{user && (
105
<button
106
className={`filter-tab ${filter === "my-tags" ? "active" : ""}`}
107
-
onClick={() => setFilter("my-tags")}
108
>
109
My Feed
110
</button>
111
)}
112
<button
113
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
114
-
onClick={() => setFilter("commenting")}
115
>
116
Annotations
117
</button>
118
<button
119
className={`filter-tab ${filter === "highlighting" ? "active" : ""}`}
120
-
onClick={() => setFilter("highlighting")}
121
>
122
Highlights
123
</button>
124
<button
125
className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`}
126
-
onClick={() => setFilter("bookmarking")}
127
>
128
Bookmarks
129
</button>
···
12
export default function Feed() {
13
const [searchParams, setSearchParams] = useSearchParams();
14
const tagFilter = searchParams.get("tag");
15
+
const filter = searchParams.get("filter") || "all";
16
+
17
const [annotations, setAnnotations] = useState([]);
18
const [loading, setLoading] = useState(true);
19
const [error, setError] = useState(null);
20
+
21
+
const updateFilter = (newFilter) => {
22
+
setSearchParams(
23
+
(prev) => {
24
+
const next = new URLSearchParams(prev);
25
+
next.set("filter", newFilter);
26
+
return next;
27
+
},
28
+
{ replace: true },
29
+
);
30
+
};
31
+
32
const [collectionModalState, setCollectionModalState] = useState({
33
isOpen: false,
34
uri: null,
···
41
try {
42
setLoading(true);
43
let creatorDid = "";
44
+
45
+
if (filter === "my-tags") {
46
+
if (user?.did) {
47
+
creatorDid = user.did;
48
+
} else {
49
+
setAnnotations([]);
50
+
setLoading(false);
51
+
return;
52
+
}
53
}
54
55
const data = await getAnnotationFeed(
···
103
Filtering by tag: <strong>#{tagFilter}</strong>
104
</span>
105
<button
106
+
onClick={() =>
107
+
setSearchParams((prev) => {
108
+
const next = new URLSearchParams(prev);
109
+
next.delete("tag");
110
+
return next;
111
+
})
112
+
}
113
className="btn btn-sm"
114
style={{ padding: "2px 8px", fontSize: "0.8rem" }}
115
>
···
123
<div className="feed-filters">
124
<button
125
className={`filter-tab ${filter === "all" ? "active" : ""}`}
126
+
onClick={() => updateFilter("all")}
127
>
128
All
129
</button>
130
{user && (
131
<button
132
className={`filter-tab ${filter === "my-tags" ? "active" : ""}`}
133
+
onClick={() => updateFilter("my-tags")}
134
>
135
My Feed
136
</button>
137
)}
138
<button
139
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
140
+
onClick={() => updateFilter("commenting")}
141
>
142
Annotations
143
</button>
144
<button
145
className={`filter-tab ${filter === "highlighting" ? "active" : ""}`}
146
+
onClick={() => updateFilter("highlighting")}
147
>
148
Highlights
149
</button>
150
<button
151
className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`}
152
+
onClick={() => updateFilter("bookmarking")}
153
>
154
Bookmarks
155
</button>
+5
-1
web/src/pages/New.jsx
···
84
85
<div className="card">
86
<Composer
87
-
url={url || initialUrl}
0
0
0
0
88
selector={initialSelector}
89
onSuccess={handleSuccess}
90
onCancel={() => navigate(-1)}
···
84
85
<div className="card">
86
<Composer
87
+
url={
88
+
(url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl)
89
+
? `https://${url || initialUrl}`
90
+
: url || initialUrl
91
+
}
92
selector={initialSelector}
93
onSuccess={handleSuccess}
94
onCancel={() => navigate(-1)}
+1
-17
web/src/pages/Profile.jsx
···
130
</div>
131
);
132
}
133
-
return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />);
134
-
}
135
-
if (activeTab === "bookmarks") {
136
-
if (bookmarks.length === 0) {
137
-
return (
138
-
<div className="empty-state">
139
-
<div className="empty-state-icon">
140
-
<BookmarkIcon size={32} />
141
-
</div>
142
-
<h3 className="empty-state-title">No bookmarks</h3>
143
-
<p className="empty-state-text">
144
-
This user hasn't bookmarked any pages.
145
-
</p>
146
-
</div>
147
-
);
148
-
}
149
-
return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />);
150
}
151
152
if (activeTab === "collections") {
···
130
</div>
131
);
132
}
133
+
return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
134
}
135
136
if (activeTab === "collections") {