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