tangled
alpha
login
or
join now
margin.at
/
margin
90
fork
atom
Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
90
fork
atom
overview
issues
4
pulls
1
pipelines
init moderation
scanash.com
1 month ago
856910c9
eaaa8aac
+4609
-128
29 changed files
expand all
collapse all
unified
split
backend
internal
api
annotations.go
handler.go
hydration.go
moderation.go
preferences.go
profile.go
config
config.go
db
db.go
queries_keys.go
queries_moderation.go
firehose
ingester.go
sync
service.go
xrpc
records.go
lexicons
at
margin
annotation.json
bookmark.json
highlight.json
preferences.json
web
src
App.tsx
api
client.ts
components
common
Card.tsx
MoreMenu.tsx
feed
Composer.tsx
modals
EditItemModal.tsx
ReportModal.tsx
store
preferences.ts
types.ts
views
core
AdminModeration.tsx
Settings.tsx
profile
Profile.tsx
+100
-4
backend/internal/api/annotations.go
···
28
Selector json.RawMessage `json:"selector,omitempty"`
29
Title string `json:"title,omitempty"`
30
Tags []string `json:"tags,omitempty"`
0
31
}
32
33
type CreateAnnotationResponse struct {
···
138
record.Facets = facets
139
}
140
0
0
0
0
0
0
0
0
0
141
var result *xrpc.CreateRecordOutput
142
143
if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil {
···
213
log.Printf("Warning: failed to index annotation in local DB: %v", err)
214
}
215
0
0
0
0
0
0
216
w.Header().Set("Content-Type", "application/json")
217
json.NewEncoder(w).Encode(CreateAnnotationResponse{
218
URI: result.URI,
···
262
}
263
264
type UpdateAnnotationRequest struct {
265
-
Text string `json:"text"`
266
-
Tags []string `json:"tags"`
0
267
}
268
269
func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
···
336
record.Tags = nil
337
}
338
0
0
0
0
0
0
0
0
0
339
if err := record.Validate(); err != nil {
340
return fmt.Errorf("validation failed: %w", err)
341
}
···
351
})
352
353
if err != nil {
0
354
http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError)
355
return
356
}
357
358
s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID)
0
0
0
0
0
0
0
0
0
0
0
359
360
w.Header().Set("Content-Type", "application/json")
361
json.NewEncoder(w).Encode(map[string]interface{}{
···
603
Selector json.RawMessage `json:"selector"`
604
Color string `json:"color,omitempty"`
605
Tags []string `json:"tags,omitempty"`
0
606
}
607
608
func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
···
625
626
urlHash := db.HashURL(req.URL)
627
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags)
0
0
0
0
0
0
0
0
0
628
629
if err := record.Validate(); err != nil {
630
http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
···
691
return
692
}
693
0
0
0
0
0
0
694
w.Header().Set("Content-Type", "application/json")
695
json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
696
}
···
828
}
829
830
type UpdateHighlightRequest struct {
831
-
Color string `json:"color"`
832
-
Tags []string `json:"tags,omitempty"`
0
833
}
834
835
func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) {
···
880
record.Tags = req.Tags
881
}
882
0
0
0
0
0
0
0
0
0
883
if err := record.Validate(); err != nil {
884
return fmt.Errorf("validation failed: %w", err)
885
}
···
906
}
907
s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID)
908
0
0
0
0
0
0
0
0
0
0
0
909
w.Header().Set("Content-Type", "application/json")
910
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
911
}
···
914
Title string `json:"title"`
915
Description string `json:"description"`
916
Tags []string `json:"tags,omitempty"`
0
917
}
918
919
func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) {
···
967
record.Tags = req.Tags
968
}
969
0
0
0
0
0
0
0
0
0
970
if err := record.Validate(); err != nil {
971
return fmt.Errorf("validation failed: %w", err)
972
}
···
992
tagsJSON = string(b)
993
}
994
s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID)
0
0
0
0
0
0
0
0
0
0
0
995
996
w.Header().Set("Content-Type", "application/json")
997
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
···
28
Selector json.RawMessage `json:"selector,omitempty"`
29
Title string `json:"title,omitempty"`
30
Tags []string `json:"tags,omitempty"`
31
+
Labels []string `json:"labels,omitempty"`
32
}
33
34
type CreateAnnotationResponse struct {
···
139
record.Facets = facets
140
}
141
142
+
validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
143
+
var validLabels []string
144
+
for _, l := range req.Labels {
145
+
if validSelfLabels[l] {
146
+
validLabels = append(validLabels, l)
147
+
}
148
+
}
149
+
record.Labels = xrpc.NewSelfLabels(validLabels)
150
+
151
var result *xrpc.CreateRecordOutput
152
153
if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil {
···
223
log.Printf("Warning: failed to index annotation in local DB: %v", err)
224
}
225
226
+
for _, label := range validLabels {
227
+
if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil {
228
+
log.Printf("Warning: failed to create self-label %s: %v", label, err)
229
+
}
230
+
}
231
+
232
w.Header().Set("Content-Type", "application/json")
233
json.NewEncoder(w).Encode(CreateAnnotationResponse{
234
URI: result.URI,
···
278
}
279
280
type UpdateAnnotationRequest struct {
281
+
Text string `json:"text"`
282
+
Tags []string `json:"tags"`
283
+
Labels []string `json:"labels,omitempty"`
284
}
285
286
func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
···
353
record.Tags = nil
354
}
355
356
+
updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
357
+
var updateLabels []string
358
+
for _, l := range req.Labels {
359
+
if updateValidLabels[l] {
360
+
updateLabels = append(updateLabels, l)
361
+
}
362
+
}
363
+
record.Labels = xrpc.NewSelfLabels(updateLabels)
364
+
365
if err := record.Validate(); err != nil {
366
return fmt.Errorf("validation failed: %w", err)
367
}
···
377
})
378
379
if err != nil {
380
+
log.Printf("[UpdateAnnotation] Failed: %v", err)
381
http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError)
382
return
383
}
384
385
s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID)
386
+
387
+
validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
388
+
var validLabels []string
389
+
for _, l := range req.Labels {
390
+
if validSelfLabels[l] {
391
+
validLabels = append(validLabels, l)
392
+
}
393
+
}
394
+
if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil {
395
+
log.Printf("Warning: failed to sync self-labels: %v", err)
396
+
}
397
398
w.Header().Set("Content-Type", "application/json")
399
json.NewEncoder(w).Encode(map[string]interface{}{
···
641
Selector json.RawMessage `json:"selector"`
642
Color string `json:"color,omitempty"`
643
Tags []string `json:"tags,omitempty"`
644
+
Labels []string `json:"labels,omitempty"`
645
}
646
647
func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
···
664
665
urlHash := db.HashURL(req.URL)
666
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags)
667
+
668
+
validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
669
+
var validLabels []string
670
+
for _, l := range req.Labels {
671
+
if validSelfLabels[l] {
672
+
validLabels = append(validLabels, l)
673
+
}
674
+
}
675
+
record.Labels = xrpc.NewSelfLabels(validLabels)
676
677
if err := record.Validate(); err != nil {
678
http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
···
739
return
740
}
741
742
+
for _, label := range validLabels {
743
+
if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil {
744
+
log.Printf("Warning: failed to create self-label %s: %v", label, err)
745
+
}
746
+
}
747
+
748
w.Header().Set("Content-Type", "application/json")
749
json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
750
}
···
882
}
883
884
type UpdateHighlightRequest struct {
885
+
Color string `json:"color"`
886
+
Tags []string `json:"tags,omitempty"`
887
+
Labels []string `json:"labels,omitempty"`
888
}
889
890
func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) {
···
935
record.Tags = req.Tags
936
}
937
938
+
updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
939
+
var updateLabels []string
940
+
for _, l := range req.Labels {
941
+
if updateValidLabels[l] {
942
+
updateLabels = append(updateLabels, l)
943
+
}
944
+
}
945
+
record.Labels = xrpc.NewSelfLabels(updateLabels)
946
+
947
if err := record.Validate(); err != nil {
948
return fmt.Errorf("validation failed: %w", err)
949
}
···
970
}
971
s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID)
972
973
+
validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
974
+
var validLabels []string
975
+
for _, l := range req.Labels {
976
+
if validSelfLabels[l] {
977
+
validLabels = append(validLabels, l)
978
+
}
979
+
}
980
+
if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil {
981
+
log.Printf("Warning: failed to sync self-labels: %v", err)
982
+
}
983
+
984
w.Header().Set("Content-Type", "application/json")
985
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
986
}
···
989
Title string `json:"title"`
990
Description string `json:"description"`
991
Tags []string `json:"tags,omitempty"`
992
+
Labels []string `json:"labels,omitempty"`
993
}
994
995
func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) {
···
1043
record.Tags = req.Tags
1044
}
1045
1046
+
updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
1047
+
var updateLabels []string
1048
+
for _, l := range req.Labels {
1049
+
if updateValidLabels[l] {
1050
+
updateLabels = append(updateLabels, l)
1051
+
}
1052
+
}
1053
+
record.Labels = xrpc.NewSelfLabels(updateLabels)
1054
+
1055
if err := record.Validate(); err != nil {
1056
return fmt.Errorf("validation failed: %w", err)
1057
}
···
1077
tagsJSON = string(b)
1078
}
1079
s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID)
1080
+
1081
+
validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true}
1082
+
var validLabels []string
1083
+
for _, l := range req.Labels {
1084
+
if validSelfLabels[l] {
1085
+
validLabels = append(validLabels, l)
1086
+
}
1087
+
}
1088
+
if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil {
1089
+
log.Printf("Warning: failed to sync self-labels: %v", err)
1090
+
}
1091
1092
w.Header().Set("Content-Type", "application/json")
1093
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
+57
-2
backend/internal/api/handler.go
···
27
refresher *TokenRefresher
28
apiKeys *APIKeyHandler
29
syncService *internal_sync.Service
0
30
}
31
32
func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher, syncService *internal_sync.Service) *Handler {
···
36
refresher: refresher,
37
apiKeys: NewAPIKeyHandler(database, refresher),
38
syncService: syncService,
0
39
}
40
}
41
···
93
94
r.Get("/preferences", h.GetPreferences)
95
r.Put("/preferences", h.UpdatePreferences)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
96
})
97
}
98
···
423
feed = filtered
424
}
425
426
-
// ...
0
427
switch feedType {
428
case "popular":
429
sortFeedByPopularity(feed)
···
447
} else {
448
feed = []interface{}{}
449
}
450
-
// ...
451
452
if len(feed) > limit {
453
feed = feed[:limit]
···
1514
}
1515
return did
1516
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
27
refresher *TokenRefresher
28
apiKeys *APIKeyHandler
29
syncService *internal_sync.Service
30
+
moderation *ModerationHandler
31
}
32
33
func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher, syncService *internal_sync.Service) *Handler {
···
37
refresher: refresher,
38
apiKeys: NewAPIKeyHandler(database, refresher),
39
syncService: syncService,
40
+
moderation: NewModerationHandler(database, refresher),
41
}
42
}
43
···
95
96
r.Get("/preferences", h.GetPreferences)
97
r.Put("/preferences", h.UpdatePreferences)
98
+
99
+
r.Post("/moderation/block", h.moderation.BlockUser)
100
+
r.Delete("/moderation/block", h.moderation.UnblockUser)
101
+
r.Get("/moderation/blocks", h.moderation.GetBlocks)
102
+
r.Post("/moderation/mute", h.moderation.MuteUser)
103
+
r.Delete("/moderation/mute", h.moderation.UnmuteUser)
104
+
r.Get("/moderation/mutes", h.moderation.GetMutes)
105
+
r.Get("/moderation/relationship", h.moderation.GetRelationship)
106
+
r.Post("/moderation/report", h.moderation.CreateReport)
107
+
r.Get("/moderation/admin/check", h.moderation.AdminCheckAccess)
108
+
r.Get("/moderation/admin/reports", h.moderation.AdminGetReports)
109
+
r.Get("/moderation/admin/report", h.moderation.AdminGetReport)
110
+
r.Post("/moderation/admin/action", h.moderation.AdminTakeAction)
111
+
r.Post("/moderation/admin/label", h.moderation.AdminCreateLabel)
112
+
r.Delete("/moderation/admin/label", h.moderation.AdminDeleteLabel)
113
+
r.Get("/moderation/admin/labels", h.moderation.AdminGetLabels)
114
+
r.Get("/moderation/labeler", h.moderation.GetLabelerInfo)
115
})
116
}
117
···
442
feed = filtered
443
}
444
445
+
feed = h.filterFeedByModeration(feed, viewerDID)
446
+
447
switch feedType {
448
case "popular":
449
sortFeedByPopularity(feed)
···
467
} else {
468
feed = []interface{}{}
469
}
0
470
471
if len(feed) > limit {
472
feed = feed[:limit]
···
1533
}
1534
return did
1535
}
1536
+
1537
+
func getItemAuthorDID(item interface{}) string {
1538
+
switch v := item.(type) {
1539
+
case APIAnnotation:
1540
+
return v.Author.DID
1541
+
case APIHighlight:
1542
+
return v.Author.DID
1543
+
case APIBookmark:
1544
+
return v.Author.DID
1545
+
case APICollectionItem:
1546
+
return v.Author.DID
1547
+
default:
1548
+
return ""
1549
+
}
1550
+
}
1551
+
1552
+
func (h *Handler) filterFeedByModeration(feed []interface{}, viewerDID string) []interface{} {
1553
+
if viewerDID == "" {
1554
+
return feed
1555
+
}
1556
+
1557
+
hiddenDIDs, err := h.db.GetAllHiddenDIDs(viewerDID)
1558
+
if err != nil || len(hiddenDIDs) == 0 {
1559
+
return feed
1560
+
}
1561
+
1562
+
var filtered []interface{}
1563
+
for _, item := range feed {
1564
+
authorDID := getItemAuthorDID(item)
1565
+
if authorDID != "" && hiddenDIDs[authorDID] {
1566
+
continue
1567
+
}
1568
+
filtered = append(filtered, item)
1569
+
}
1570
+
return filtered
1571
+
}
+130
-25
backend/internal/api/hydration.go
···
61
Name string `json:"name"`
62
}
63
0
0
0
0
0
0
64
type APIAnnotation struct {
65
ID string `json:"id"`
66
CID string `json:"cid"`
···
76
LikeCount int `json:"likeCount"`
77
ReplyCount int `json:"replyCount"`
78
ViewerHasLiked bool `json:"viewerHasLiked"`
0
79
}
80
81
type APIHighlight struct {
82
-
ID string `json:"id"`
83
-
Type string `json:"type"`
84
-
Motivation string `json:"motivation"`
85
-
Author Author `json:"creator"`
86
-
Target APITarget `json:"target"`
87
-
Color string `json:"color,omitempty"`
88
-
Tags []string `json:"tags,omitempty"`
89
-
CreatedAt time.Time `json:"created"`
90
-
CID string `json:"cid,omitempty"`
91
-
LikeCount int `json:"likeCount"`
92
-
ReplyCount int `json:"replyCount"`
93
-
ViewerHasLiked bool `json:"viewerHasLiked"`
0
94
}
95
96
type APIBookmark struct {
97
-
ID string `json:"id"`
98
-
Type string `json:"type"`
99
-
Motivation string `json:"motivation"`
100
-
Author Author `json:"creator"`
101
-
Source string `json:"source"`
102
-
Title string `json:"title,omitempty"`
103
-
Description string `json:"description,omitempty"`
104
-
Tags []string `json:"tags,omitempty"`
105
-
CreatedAt time.Time `json:"created"`
106
-
CID string `json:"cid,omitempty"`
107
-
LikeCount int `json:"likeCount"`
108
-
ReplyCount int `json:"replyCount"`
109
-
ViewerHasLiked bool `json:"viewerHasLiked"`
0
110
}
111
112
type APIReply struct {
···
208
defer cancel()
209
likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID)
210
0
0
0
0
0
0
211
result := make([]APIAnnotation, len(annotations))
212
for i, a := range annotations {
213
var body *APIBody
···
265
},
266
CreatedAt: a.CreatedAt,
267
IndexedAt: a.IndexedAt,
0
268
}
269
270
result[i].LikeCount = likeCounts[a.URI]
···
293
defer cancel()
294
likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID)
295
0
0
0
0
0
0
296
result := make([]APIHighlight, len(highlights))
297
for i, h := range highlights {
298
var selector *APISelector
···
335
Tags: tags,
336
CreatedAt: h.CreatedAt,
337
CID: cid,
0
338
}
339
340
result[i].LikeCount = likeCounts[h.URI]
···
363
defer cancel()
364
likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID)
365
0
0
0
0
0
0
366
result := make([]APIBookmark, len(bookmarks))
367
for i, b := range bookmarks {
368
var tags []string
···
396
Tags: tags,
397
CreatedAt: b.CreatedAt,
398
CID: cid,
0
399
}
400
result[i].LikeCount = likeCounts[b.URI]
401
result[i].ReplyCount = replyCounts[b.URI]
···
762
763
return result, nil
764
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
61
Name string `json:"name"`
62
}
63
64
+
type APILabel struct {
65
+
Val string `json:"val"`
66
+
Src string `json:"src"`
67
+
Scope string `json:"scope"`
68
+
}
69
+
70
type APIAnnotation struct {
71
ID string `json:"id"`
72
CID string `json:"cid"`
···
82
LikeCount int `json:"likeCount"`
83
ReplyCount int `json:"replyCount"`
84
ViewerHasLiked bool `json:"viewerHasLiked"`
85
+
Labels []APILabel `json:"labels,omitempty"`
86
}
87
88
type APIHighlight struct {
89
+
ID string `json:"id"`
90
+
Type string `json:"type"`
91
+
Motivation string `json:"motivation"`
92
+
Author Author `json:"creator"`
93
+
Target APITarget `json:"target"`
94
+
Color string `json:"color,omitempty"`
95
+
Tags []string `json:"tags,omitempty"`
96
+
CreatedAt time.Time `json:"created"`
97
+
CID string `json:"cid,omitempty"`
98
+
LikeCount int `json:"likeCount"`
99
+
ReplyCount int `json:"replyCount"`
100
+
ViewerHasLiked bool `json:"viewerHasLiked"`
101
+
Labels []APILabel `json:"labels,omitempty"`
102
}
103
104
type APIBookmark struct {
105
+
ID string `json:"id"`
106
+
Type string `json:"type"`
107
+
Motivation string `json:"motivation"`
108
+
Author Author `json:"creator"`
109
+
Source string `json:"source"`
110
+
Title string `json:"title,omitempty"`
111
+
Description string `json:"description,omitempty"`
112
+
Tags []string `json:"tags,omitempty"`
113
+
CreatedAt time.Time `json:"created"`
114
+
CID string `json:"cid,omitempty"`
115
+
LikeCount int `json:"likeCount"`
116
+
ReplyCount int `json:"replyCount"`
117
+
ViewerHasLiked bool `json:"viewerHasLiked"`
118
+
Labels []APILabel `json:"labels,omitempty"`
119
}
120
121
type APIReply struct {
···
217
defer cancel()
218
likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID)
219
220
+
subscribedLabelers := getSubscribedLabelers(database, viewerDID)
221
+
authorDIDs := collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })
222
+
labelerDIDs := appendUnique(subscribedLabelers, authorDIDs)
223
+
uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs)
224
+
didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs)
225
+
226
result := make([]APIAnnotation, len(annotations))
227
for i, a := range annotations {
228
var body *APIBody
···
280
},
281
CreatedAt: a.CreatedAt,
282
IndexedAt: a.IndexedAt,
283
+
Labels: mergeLabels(uriLabels[a.URI], didLabels[a.AuthorDID]),
284
}
285
286
result[i].LikeCount = likeCounts[a.URI]
···
309
defer cancel()
310
likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID)
311
312
+
subscribedLabelers := getSubscribedLabelers(database, viewerDID)
313
+
authorDIDs := collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })
314
+
labelerDIDs := appendUnique(subscribedLabelers, authorDIDs)
315
+
uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs)
316
+
didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs)
317
+
318
result := make([]APIHighlight, len(highlights))
319
for i, h := range highlights {
320
var selector *APISelector
···
357
Tags: tags,
358
CreatedAt: h.CreatedAt,
359
CID: cid,
360
+
Labels: mergeLabels(uriLabels[h.URI], didLabels[h.AuthorDID]),
361
}
362
363
result[i].LikeCount = likeCounts[h.URI]
···
386
defer cancel()
387
likeCounts, replyCounts, viewerLikes := fetchCounts(ctx, database, uris, viewerDID)
388
389
+
subscribedLabelers := getSubscribedLabelers(database, viewerDID)
390
+
authorDIDs := collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })
391
+
labelerDIDs := appendUnique(subscribedLabelers, authorDIDs)
392
+
uriLabels, _ := database.GetContentLabelsForURIs(uris, labelerDIDs)
393
+
didLabels, _ := database.GetContentLabelsForDIDs(authorDIDs, labelerDIDs)
394
+
395
result := make([]APIBookmark, len(bookmarks))
396
for i, b := range bookmarks {
397
var tags []string
···
425
Tags: tags,
426
CreatedAt: b.CreatedAt,
427
CID: cid,
428
+
Labels: mergeLabels(uriLabels[b.URI], didLabels[b.AuthorDID]),
429
}
430
result[i].LikeCount = likeCounts[b.URI]
431
result[i].ReplyCount = replyCounts[b.URI]
···
792
793
return result, nil
794
}
795
+
796
+
func mergeLabels(uriLabels []db.ContentLabel, didLabels []db.ContentLabel) []APILabel {
797
+
seen := make(map[string]bool)
798
+
var labels []APILabel
799
+
for _, l := range uriLabels {
800
+
key := l.Val + ":" + l.Src
801
+
if !seen[key] {
802
+
labels = append(labels, APILabel{Val: l.Val, Src: l.Src, Scope: "content"})
803
+
seen[key] = true
804
+
}
805
+
}
806
+
for _, l := range didLabels {
807
+
key := l.Val + ":" + l.Src
808
+
if !seen[key] {
809
+
labels = append(labels, APILabel{Val: l.Val, Src: l.Src, Scope: "account"})
810
+
seen[key] = true
811
+
}
812
+
}
813
+
return labels
814
+
}
815
+
816
+
func appendUnique(base []string, extra []string) []string {
817
+
if base == nil {
818
+
return nil
819
+
}
820
+
seen := make(map[string]bool, len(base))
821
+
for _, s := range base {
822
+
seen[s] = true
823
+
}
824
+
result := make([]string, len(base))
825
+
copy(result, base)
826
+
for _, s := range extra {
827
+
if !seen[s] {
828
+
result = append(result, s)
829
+
seen[s] = true
830
+
}
831
+
}
832
+
return result
833
+
}
834
+
835
+
func getSubscribedLabelers(database *db.DB, viewerDID string) []string {
836
+
serviceDID := config.Get().ServiceDID
837
+
838
+
if viewerDID == "" {
839
+
if serviceDID != "" {
840
+
return []string{serviceDID}
841
+
}
842
+
return nil
843
+
}
844
+
845
+
prefs, err := database.GetPreferences(viewerDID)
846
+
if err != nil || prefs == nil || prefs.SubscribedLabelers == nil {
847
+
if serviceDID != "" {
848
+
return []string{serviceDID}
849
+
}
850
+
return nil
851
+
}
852
+
853
+
type sub struct {
854
+
DID string `json:"did"`
855
+
}
856
+
var subs []sub
857
+
if err := json.Unmarshal([]byte(*prefs.SubscribedLabelers), &subs); err != nil || len(subs) == 0 {
858
+
if serviceDID != "" {
859
+
return []string{serviceDID}
860
+
}
861
+
return nil
862
+
}
863
+
864
+
dids := make([]string, len(subs))
865
+
for i, s := range subs {
866
+
dids[i] = s.DID
867
+
}
868
+
return dids
869
+
}
+676
backend/internal/api/moderation.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package api
2
+
3
+
import (
4
+
"encoding/json"
5
+
"log"
6
+
"net/http"
7
+
"strconv"
8
+
9
+
"margin.at/internal/config"
10
+
"margin.at/internal/db"
11
+
)
12
+
13
+
type ModerationHandler struct {
14
+
db *db.DB
15
+
refresher *TokenRefresher
16
+
}
17
+
18
+
func NewModerationHandler(database *db.DB, refresher *TokenRefresher) *ModerationHandler {
19
+
return &ModerationHandler{db: database, refresher: refresher}
20
+
}
21
+
22
+
func (m *ModerationHandler) BlockUser(w http.ResponseWriter, r *http.Request) {
23
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
24
+
if err != nil {
25
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
26
+
return
27
+
}
28
+
29
+
var req struct {
30
+
DID string `json:"did"`
31
+
}
32
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" {
33
+
http.Error(w, "did is required", http.StatusBadRequest)
34
+
return
35
+
}
36
+
37
+
if req.DID == session.DID {
38
+
http.Error(w, "Cannot block yourself", http.StatusBadRequest)
39
+
return
40
+
}
41
+
42
+
if err := m.db.CreateBlock(session.DID, req.DID); err != nil {
43
+
log.Printf("Failed to create block: %v", err)
44
+
http.Error(w, "Failed to block user", http.StatusInternalServerError)
45
+
return
46
+
}
47
+
48
+
w.Header().Set("Content-Type", "application/json")
49
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
50
+
}
51
+
52
+
func (m *ModerationHandler) UnblockUser(w http.ResponseWriter, r *http.Request) {
53
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
54
+
if err != nil {
55
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
56
+
return
57
+
}
58
+
59
+
did := r.URL.Query().Get("did")
60
+
if did == "" {
61
+
http.Error(w, "did query parameter required", http.StatusBadRequest)
62
+
return
63
+
}
64
+
65
+
if err := m.db.DeleteBlock(session.DID, did); err != nil {
66
+
log.Printf("Failed to delete block: %v", err)
67
+
http.Error(w, "Failed to unblock user", http.StatusInternalServerError)
68
+
return
69
+
}
70
+
71
+
w.Header().Set("Content-Type", "application/json")
72
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
73
+
}
74
+
75
+
func (m *ModerationHandler) GetBlocks(w http.ResponseWriter, r *http.Request) {
76
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
77
+
if err != nil {
78
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
79
+
return
80
+
}
81
+
82
+
blocks, err := m.db.GetBlocks(session.DID)
83
+
if err != nil {
84
+
http.Error(w, "Failed to fetch blocks", http.StatusInternalServerError)
85
+
return
86
+
}
87
+
88
+
dids := make([]string, len(blocks))
89
+
for i, b := range blocks {
90
+
dids[i] = b.SubjectDID
91
+
}
92
+
profiles := fetchProfilesForDIDs(m.db, dids)
93
+
94
+
type BlockedUser struct {
95
+
DID string `json:"did"`
96
+
Author Author `json:"author"`
97
+
CreatedAt string `json:"createdAt"`
98
+
}
99
+
100
+
items := make([]BlockedUser, len(blocks))
101
+
for i, b := range blocks {
102
+
items[i] = BlockedUser{
103
+
DID: b.SubjectDID,
104
+
Author: profiles[b.SubjectDID],
105
+
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"),
106
+
}
107
+
}
108
+
109
+
w.Header().Set("Content-Type", "application/json")
110
+
json.NewEncoder(w).Encode(map[string]interface{}{"items": items})
111
+
}
112
+
113
+
114
+
func (m *ModerationHandler) MuteUser(w http.ResponseWriter, r *http.Request) {
115
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
116
+
if err != nil {
117
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
118
+
return
119
+
}
120
+
121
+
var req struct {
122
+
DID string `json:"did"`
123
+
}
124
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" {
125
+
http.Error(w, "did is required", http.StatusBadRequest)
126
+
return
127
+
}
128
+
129
+
if req.DID == session.DID {
130
+
http.Error(w, "Cannot mute yourself", http.StatusBadRequest)
131
+
return
132
+
}
133
+
134
+
if err := m.db.CreateMute(session.DID, req.DID); err != nil {
135
+
log.Printf("Failed to create mute: %v", err)
136
+
http.Error(w, "Failed to mute user", http.StatusInternalServerError)
137
+
return
138
+
}
139
+
140
+
w.Header().Set("Content-Type", "application/json")
141
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
142
+
}
143
+
144
+
func (m *ModerationHandler) UnmuteUser(w http.ResponseWriter, r *http.Request) {
145
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
146
+
if err != nil {
147
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
148
+
return
149
+
}
150
+
151
+
did := r.URL.Query().Get("did")
152
+
if did == "" {
153
+
http.Error(w, "did query parameter required", http.StatusBadRequest)
154
+
return
155
+
}
156
+
157
+
if err := m.db.DeleteMute(session.DID, did); err != nil {
158
+
log.Printf("Failed to delete mute: %v", err)
159
+
http.Error(w, "Failed to unmute user", http.StatusInternalServerError)
160
+
return
161
+
}
162
+
163
+
w.Header().Set("Content-Type", "application/json")
164
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
165
+
}
166
+
167
+
func (m *ModerationHandler) GetMutes(w http.ResponseWriter, r *http.Request) {
168
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
169
+
if err != nil {
170
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
171
+
return
172
+
}
173
+
174
+
mutes, err := m.db.GetMutes(session.DID)
175
+
if err != nil {
176
+
http.Error(w, "Failed to fetch mutes", http.StatusInternalServerError)
177
+
return
178
+
}
179
+
180
+
dids := make([]string, len(mutes))
181
+
for i, mu := range mutes {
182
+
dids[i] = mu.SubjectDID
183
+
}
184
+
profiles := fetchProfilesForDIDs(m.db, dids)
185
+
186
+
type MutedUser struct {
187
+
DID string `json:"did"`
188
+
Author Author `json:"author"`
189
+
CreatedAt string `json:"createdAt"`
190
+
}
191
+
192
+
items := make([]MutedUser, len(mutes))
193
+
for i, mu := range mutes {
194
+
items[i] = MutedUser{
195
+
DID: mu.SubjectDID,
196
+
Author: profiles[mu.SubjectDID],
197
+
CreatedAt: mu.CreatedAt.Format("2006-01-02T15:04:05Z"),
198
+
}
199
+
}
200
+
201
+
w.Header().Set("Content-Type", "application/json")
202
+
json.NewEncoder(w).Encode(map[string]interface{}{"items": items})
203
+
}
204
+
205
+
func (m *ModerationHandler) GetRelationship(w http.ResponseWriter, r *http.Request) {
206
+
viewerDID := m.getViewerDID(r)
207
+
subjectDID := r.URL.Query().Get("did")
208
+
209
+
if subjectDID == "" {
210
+
http.Error(w, "did query parameter required", http.StatusBadRequest)
211
+
return
212
+
}
213
+
214
+
blocked, muted, blockedBy, err := m.db.GetViewerRelationship(viewerDID, subjectDID)
215
+
if err != nil {
216
+
http.Error(w, "Failed to get relationship", http.StatusInternalServerError)
217
+
return
218
+
}
219
+
220
+
w.Header().Set("Content-Type", "application/json")
221
+
json.NewEncoder(w).Encode(map[string]interface{}{
222
+
"blocking": blocked,
223
+
"muting": muted,
224
+
"blockedBy": blockedBy,
225
+
})
226
+
}
227
+
228
+
229
+
func (m *ModerationHandler) CreateReport(w http.ResponseWriter, r *http.Request) {
230
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
231
+
if err != nil {
232
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
233
+
return
234
+
}
235
+
236
+
var req struct {
237
+
SubjectDID string `json:"subjectDid"`
238
+
SubjectURI *string `json:"subjectUri,omitempty"`
239
+
ReasonType string `json:"reasonType"`
240
+
ReasonText *string `json:"reasonText,omitempty"`
241
+
}
242
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
243
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
244
+
return
245
+
}
246
+
247
+
if req.SubjectDID == "" || req.ReasonType == "" {
248
+
http.Error(w, "subjectDid and reasonType are required", http.StatusBadRequest)
249
+
return
250
+
}
251
+
252
+
validReasons := map[string]bool{
253
+
"spam": true,
254
+
"violation": true,
255
+
"misleading": true,
256
+
"sexual": true,
257
+
"rude": true,
258
+
"other": true,
259
+
}
260
+
261
+
if !validReasons[req.ReasonType] {
262
+
http.Error(w, "Invalid reasonType", http.StatusBadRequest)
263
+
return
264
+
}
265
+
266
+
id, err := m.db.CreateReport(session.DID, req.SubjectDID, req.SubjectURI, req.ReasonType, req.ReasonText)
267
+
if err != nil {
268
+
log.Printf("Failed to create report: %v", err)
269
+
http.Error(w, "Failed to submit report", http.StatusInternalServerError)
270
+
return
271
+
}
272
+
273
+
w.Header().Set("Content-Type", "application/json")
274
+
json.NewEncoder(w).Encode(map[string]interface{}{"id": id, "status": "ok"})
275
+
}
276
+
277
+
278
+
func (m *ModerationHandler) AdminGetReports(w http.ResponseWriter, r *http.Request) {
279
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
280
+
if err != nil {
281
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
282
+
return
283
+
}
284
+
285
+
if !config.Get().IsAdmin(session.DID) {
286
+
http.Error(w, "Forbidden", http.StatusForbidden)
287
+
return
288
+
}
289
+
290
+
status := r.URL.Query().Get("status")
291
+
limit := parseIntParam(r, "limit", 50)
292
+
offset := parseIntParam(r, "offset", 0)
293
+
294
+
reports, err := m.db.GetReports(status, limit, offset)
295
+
if err != nil {
296
+
http.Error(w, "Failed to fetch reports", http.StatusInternalServerError)
297
+
return
298
+
}
299
+
300
+
uniqueDIDs := make(map[string]bool)
301
+
for _, rpt := range reports {
302
+
uniqueDIDs[rpt.ReporterDID] = true
303
+
uniqueDIDs[rpt.SubjectDID] = true
304
+
}
305
+
dids := make([]string, 0, len(uniqueDIDs))
306
+
for did := range uniqueDIDs {
307
+
dids = append(dids, did)
308
+
}
309
+
profiles := fetchProfilesForDIDs(m.db, dids)
310
+
311
+
type HydratedReport struct {
312
+
ID int `json:"id"`
313
+
Reporter Author `json:"reporter"`
314
+
Subject Author `json:"subject"`
315
+
SubjectURI *string `json:"subjectUri,omitempty"`
316
+
ReasonType string `json:"reasonType"`
317
+
ReasonText *string `json:"reasonText,omitempty"`
318
+
Status string `json:"status"`
319
+
CreatedAt string `json:"createdAt"`
320
+
ResolvedAt *string `json:"resolvedAt,omitempty"`
321
+
ResolvedBy *string `json:"resolvedBy,omitempty"`
322
+
}
323
+
324
+
items := make([]HydratedReport, len(reports))
325
+
for i, rpt := range reports {
326
+
items[i] = HydratedReport{
327
+
ID: rpt.ID,
328
+
Reporter: profiles[rpt.ReporterDID],
329
+
Subject: profiles[rpt.SubjectDID],
330
+
SubjectURI: rpt.SubjectURI,
331
+
ReasonType: rpt.ReasonType,
332
+
ReasonText: rpt.ReasonText,
333
+
Status: rpt.Status,
334
+
CreatedAt: rpt.CreatedAt.Format("2006-01-02T15:04:05Z"),
335
+
}
336
+
if rpt.ResolvedAt != nil {
337
+
resolved := rpt.ResolvedAt.Format("2006-01-02T15:04:05Z")
338
+
items[i].ResolvedAt = &resolved
339
+
}
340
+
items[i].ResolvedBy = rpt.ResolvedBy
341
+
}
342
+
343
+
pendingCount, _ := m.db.GetReportCount("pending")
344
+
totalCount, _ := m.db.GetReportCount("")
345
+
346
+
w.Header().Set("Content-Type", "application/json")
347
+
json.NewEncoder(w).Encode(map[string]interface{}{
348
+
"items": items,
349
+
"totalItems": totalCount,
350
+
"pendingCount": pendingCount,
351
+
})
352
+
}
353
+
354
+
func (m *ModerationHandler) AdminTakeAction(w http.ResponseWriter, r *http.Request) {
355
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
356
+
if err != nil {
357
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
358
+
return
359
+
}
360
+
361
+
if !config.Get().IsAdmin(session.DID) {
362
+
http.Error(w, "Forbidden", http.StatusForbidden)
363
+
return
364
+
}
365
+
366
+
var req struct {
367
+
ReportID int `json:"reportId"`
368
+
Action string `json:"action"`
369
+
Comment *string `json:"comment,omitempty"`
370
+
}
371
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
372
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
373
+
return
374
+
}
375
+
376
+
validActions := map[string]bool{
377
+
"acknowledge": true,
378
+
"escalate": true,
379
+
"takedown": true,
380
+
"dismiss": true,
381
+
}
382
+
383
+
if !validActions[req.Action] {
384
+
http.Error(w, "Invalid action", http.StatusBadRequest)
385
+
return
386
+
}
387
+
388
+
report, err := m.db.GetReport(req.ReportID)
389
+
if err != nil {
390
+
http.Error(w, "Report not found", http.StatusNotFound)
391
+
return
392
+
}
393
+
394
+
if err := m.db.CreateModerationAction(req.ReportID, session.DID, req.Action, req.Comment); err != nil {
395
+
log.Printf("Failed to create moderation action: %v", err)
396
+
http.Error(w, "Failed to take action", http.StatusInternalServerError)
397
+
return
398
+
}
399
+
400
+
resolveStatus := "resolved"
401
+
switch req.Action {
402
+
case "dismiss":
403
+
resolveStatus = "dismissed"
404
+
case "escalate":
405
+
resolveStatus = "escalated"
406
+
case "takedown":
407
+
resolveStatus = "resolved"
408
+
if report.SubjectURI != nil && *report.SubjectURI != "" {
409
+
m.deleteContent(*report.SubjectURI)
410
+
}
411
+
case "acknowledge":
412
+
resolveStatus = "acknowledged"
413
+
}
414
+
415
+
if err := m.db.ResolveReport(req.ReportID, session.DID, resolveStatus); err != nil {
416
+
log.Printf("Failed to resolve report: %v", err)
417
+
}
418
+
419
+
w.Header().Set("Content-Type", "application/json")
420
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
421
+
}
422
+
423
+
func (m *ModerationHandler) AdminGetReport(w http.ResponseWriter, r *http.Request) {
424
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
425
+
if err != nil {
426
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
427
+
return
428
+
}
429
+
430
+
if !config.Get().IsAdmin(session.DID) {
431
+
http.Error(w, "Forbidden", http.StatusForbidden)
432
+
return
433
+
}
434
+
435
+
idStr := r.URL.Query().Get("id")
436
+
id, err := strconv.Atoi(idStr)
437
+
if err != nil {
438
+
http.Error(w, "Invalid report ID", http.StatusBadRequest)
439
+
return
440
+
}
441
+
442
+
report, err := m.db.GetReport(id)
443
+
if err != nil {
444
+
http.Error(w, "Report not found", http.StatusNotFound)
445
+
return
446
+
}
447
+
448
+
actions, _ := m.db.GetReportActions(id)
449
+
450
+
profiles := fetchProfilesForDIDs(m.db, []string{report.ReporterDID, report.SubjectDID})
451
+
452
+
w.Header().Set("Content-Type", "application/json")
453
+
json.NewEncoder(w).Encode(map[string]interface{}{
454
+
"report": report,
455
+
"reporter": profiles[report.ReporterDID],
456
+
"subject": profiles[report.SubjectDID],
457
+
"actions": actions,
458
+
})
459
+
}
460
+
461
+
func (m *ModerationHandler) AdminCheckAccess(w http.ResponseWriter, r *http.Request) {
462
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
463
+
if err != nil {
464
+
w.Header().Set("Content-Type", "application/json")
465
+
json.NewEncoder(w).Encode(map[string]bool{"isAdmin": false})
466
+
return
467
+
}
468
+
469
+
w.Header().Set("Content-Type", "application/json")
470
+
json.NewEncoder(w).Encode(map[string]bool{"isAdmin": config.Get().IsAdmin(session.DID)})
471
+
}
472
+
473
+
func (m *ModerationHandler) deleteContent(uri string) {
474
+
m.db.Exec("DELETE FROM annotations WHERE uri = $1", uri)
475
+
m.db.Exec("DELETE FROM highlights WHERE uri = $1", uri)
476
+
m.db.Exec("DELETE FROM bookmarks WHERE uri = $1", uri)
477
+
m.db.Exec("DELETE FROM replies WHERE uri = $1", uri)
478
+
}
479
+
480
+
481
+
func (m *ModerationHandler) AdminCreateLabel(w http.ResponseWriter, r *http.Request) {
482
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
483
+
if err != nil {
484
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
485
+
return
486
+
}
487
+
488
+
if !config.Get().IsAdmin(session.DID) {
489
+
http.Error(w, "Forbidden", http.StatusForbidden)
490
+
return
491
+
}
492
+
493
+
var req struct {
494
+
Src string `json:"src"`
495
+
URI string `json:"uri"`
496
+
Val string `json:"val"`
497
+
}
498
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
499
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
500
+
return
501
+
}
502
+
503
+
if req.Val == "" {
504
+
http.Error(w, "val is required", http.StatusBadRequest)
505
+
return
506
+
}
507
+
508
+
labelerDID := config.Get().ServiceDID
509
+
if labelerDID == "" {
510
+
http.Error(w, "SERVICE_DID not configured — cannot issue labels", http.StatusInternalServerError)
511
+
return
512
+
}
513
+
514
+
targetURI := req.URI
515
+
if targetURI == "" {
516
+
targetURI = req.Src
517
+
}
518
+
if targetURI == "" {
519
+
http.Error(w, "src or uri is required", http.StatusBadRequest)
520
+
return
521
+
}
522
+
523
+
validLabels := map[string]bool{
524
+
"sexual": true,
525
+
"nudity": true,
526
+
"violence": true,
527
+
"gore": true,
528
+
"spam": true,
529
+
"misleading": true,
530
+
}
531
+
532
+
if !validLabels[req.Val] {
533
+
http.Error(w, "Invalid label value. Must be one of: sexual, nudity, violence, gore, spam, misleading", http.StatusBadRequest)
534
+
return
535
+
}
536
+
537
+
if err := m.db.CreateContentLabel(labelerDID, targetURI, req.Val, session.DID); err != nil {
538
+
log.Printf("Failed to create content label: %v", err)
539
+
http.Error(w, "Failed to create label", http.StatusInternalServerError)
540
+
return
541
+
}
542
+
543
+
w.Header().Set("Content-Type", "application/json")
544
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
545
+
}
546
+
547
+
func (m *ModerationHandler) AdminDeleteLabel(w http.ResponseWriter, r *http.Request) {
548
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
549
+
if err != nil {
550
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
551
+
return
552
+
}
553
+
554
+
if !config.Get().IsAdmin(session.DID) {
555
+
http.Error(w, "Forbidden", http.StatusForbidden)
556
+
return
557
+
}
558
+
559
+
idStr := r.URL.Query().Get("id")
560
+
id, err := strconv.Atoi(idStr)
561
+
if err != nil {
562
+
http.Error(w, "Invalid label ID", http.StatusBadRequest)
563
+
return
564
+
}
565
+
566
+
if err := m.db.DeleteContentLabel(id); err != nil {
567
+
http.Error(w, "Failed to delete label", http.StatusInternalServerError)
568
+
return
569
+
}
570
+
571
+
w.Header().Set("Content-Type", "application/json")
572
+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
573
+
}
574
+
575
+
func (m *ModerationHandler) AdminGetLabels(w http.ResponseWriter, r *http.Request) {
576
+
session, err := m.refresher.GetSessionWithAutoRefresh(r)
577
+
if err != nil {
578
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
579
+
return
580
+
}
581
+
582
+
if !config.Get().IsAdmin(session.DID) {
583
+
http.Error(w, "Forbidden", http.StatusForbidden)
584
+
return
585
+
}
586
+
587
+
limit := parseIntParam(r, "limit", 50)
588
+
offset := parseIntParam(r, "offset", 0)
589
+
590
+
labels, err := m.db.GetAllContentLabels(limit, offset)
591
+
if err != nil {
592
+
http.Error(w, "Failed to fetch labels", http.StatusInternalServerError)
593
+
return
594
+
}
595
+
596
+
uniqueDIDs := make(map[string]bool)
597
+
for _, l := range labels {
598
+
uniqueDIDs[l.CreatedBy] = true
599
+
if len(l.Src) > 4 && l.Src[:4] == "did:" {
600
+
uniqueDIDs[l.Src] = true
601
+
}
602
+
}
603
+
dids := make([]string, 0, len(uniqueDIDs))
604
+
for did := range uniqueDIDs {
605
+
dids = append(dids, did)
606
+
}
607
+
profiles := fetchProfilesForDIDs(m.db, dids)
608
+
609
+
type HydratedLabel struct {
610
+
ID int `json:"id"`
611
+
Src string `json:"src"`
612
+
URI string `json:"uri"`
613
+
Val string `json:"val"`
614
+
CreatedBy Author `json:"createdBy"`
615
+
CreatedAt string `json:"createdAt"`
616
+
Subject *Author `json:"subject,omitempty"`
617
+
}
618
+
619
+
items := make([]HydratedLabel, len(labels))
620
+
for i, l := range labels {
621
+
items[i] = HydratedLabel{
622
+
ID: l.ID,
623
+
Src: l.Src,
624
+
URI: l.URI,
625
+
Val: l.Val,
626
+
CreatedBy: profiles[l.CreatedBy],
627
+
CreatedAt: l.CreatedAt.Format("2006-01-02T15:04:05Z"),
628
+
}
629
+
if len(l.Src) > 4 && l.Src[:4] == "did:" {
630
+
subj := profiles[l.Src]
631
+
items[i].Subject = &subj
632
+
}
633
+
}
634
+
635
+
w.Header().Set("Content-Type", "application/json")
636
+
json.NewEncoder(w).Encode(map[string]interface{}{"items": items})
637
+
}
638
+
639
+
func (m *ModerationHandler) getViewerDID(r *http.Request) string {
640
+
cookie, err := r.Cookie("margin_session")
641
+
if err != nil {
642
+
return ""
643
+
}
644
+
did, _, _, _, _, err := m.db.GetSession(cookie.Value)
645
+
if err != nil {
646
+
return ""
647
+
}
648
+
return did
649
+
}
650
+
651
+
func (m *ModerationHandler) GetLabelerInfo(w http.ResponseWriter, r *http.Request) {
652
+
serviceDID := config.Get().ServiceDID
653
+
654
+
type LabelDefinition struct {
655
+
Identifier string `json:"identifier"`
656
+
Severity string `json:"severity"`
657
+
Blurs string `json:"blurs"`
658
+
Description string `json:"description"`
659
+
}
660
+
661
+
labels := []LabelDefinition{
662
+
{Identifier: "sexual", Severity: "inform", Blurs: "content", Description: "Sexual content"},
663
+
{Identifier: "nudity", Severity: "inform", Blurs: "content", Description: "Nudity"},
664
+
{Identifier: "violence", Severity: "inform", Blurs: "content", Description: "Violence"},
665
+
{Identifier: "gore", Severity: "alert", Blurs: "content", Description: "Graphic/gory content"},
666
+
{Identifier: "spam", Severity: "inform", Blurs: "content", Description: "Spam or unwanted content"},
667
+
{Identifier: "misleading", Severity: "inform", Blurs: "content", Description: "Misleading information"},
668
+
}
669
+
670
+
w.Header().Set("Content-Type", "application/json")
671
+
json.NewEncoder(w).Encode(map[string]interface{}{
672
+
"did": serviceDID,
673
+
"name": "Margin Moderation",
674
+
"labels": labels,
675
+
})
676
+
}
+69
-41
backend/internal/api/preferences.go
···
1
package api
2
3
import (
4
-
"bytes"
5
"encoding/json"
6
"fmt"
7
"net/http"
8
"time"
9
0
10
"margin.at/internal/db"
11
"margin.at/internal/xrpc"
12
)
13
0
0
0
0
0
0
0
0
0
0
14
type PreferencesResponse struct {
15
-
ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"`
0
0
16
}
17
18
func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) {
···
33
json.Unmarshal([]byte(*prefs.ExternalLinkSkippedHostnames), &hostnames)
34
}
35
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
36
w.Header().Set("Content-Type", "application/json")
37
json.NewEncoder(w).Encode(PreferencesResponse{
38
ExternalLinkSkippedHostnames: hostnames,
0
0
39
})
40
}
41
···
52
return
53
}
54
55
-
record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames)
0
0
0
0
0
0
0
0
0
0
0
0
0
56
if err := record.Validate(); err != nil {
57
http.Error(w, fmt.Sprintf("Invalid record: %v", err), http.StatusBadRequest)
58
return
59
}
60
61
-
err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error {
62
-
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", client.PDS)
63
-
64
-
body := map[string]interface{}{
65
-
"repo": session.DID,
66
-
"collection": xrpc.CollectionPreferences,
67
-
"rkey": "self",
68
-
"record": record,
69
-
}
70
-
71
-
jsonBody, err := json.Marshal(body)
72
-
if err != nil {
73
-
return err
74
-
}
75
-
76
-
req, err := http.NewRequestWithContext(r.Context(), "POST", url, bytes.NewBuffer(jsonBody))
77
-
if err != nil {
78
-
return err
79
-
}
80
-
req.Header.Set("Authorization", "Bearer "+client.AccessToken)
81
-
req.Header.Set("Content-Type", "application/json")
82
-
83
-
resp, err := http.DefaultClient.Do(req)
84
-
if err != nil {
85
-
return err
86
-
}
87
-
defer resp.Body.Close()
88
-
89
-
if resp.StatusCode != 200 {
90
-
var errResp struct {
91
-
Error string `json:"error"`
92
-
Message string `json:"message"`
93
-
}
94
-
json.NewDecoder(resp.Body).Decode(&errResp)
95
-
return fmt.Errorf("XRPC error %d: %s - %s", resp.StatusCode, errResp.Error, errResp.Message)
96
-
}
97
-
98
-
return nil
99
})
100
101
if err != nil {
0
102
http.Error(w, fmt.Sprintf("Failed to update preferences: %v", err), http.StatusInternalServerError)
103
return
104
}
···
106
createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt)
107
hostnamesJSON, _ := json.Marshal(input.ExternalLinkSkippedHostnames)
108
hostnamesStr := string(hostnamesJSON)
0
0
0
0
0
0
0
0
0
0
0
0
0
109
uri := fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionPreferences)
110
111
err = h.db.UpsertPreferences(&db.Preferences{
112
URI: uri,
113
AuthorDID: session.DID,
114
ExternalLinkSkippedHostnames: &hostnamesStr,
0
0
115
CreatedAt: createdAt,
116
IndexedAt: time.Now(),
117
})
···
1
package api
2
3
import (
0
4
"encoding/json"
5
"fmt"
6
"net/http"
7
"time"
8
9
+
"margin.at/internal/config"
10
"margin.at/internal/db"
11
"margin.at/internal/xrpc"
12
)
13
14
+
type LabelerSubscription struct {
15
+
DID string `json:"did"`
16
+
}
17
+
18
+
type LabelPreference struct {
19
+
LabelerDID string `json:"labelerDid"`
20
+
Label string `json:"label"`
21
+
Visibility string `json:"visibility"`
22
+
}
23
+
24
type PreferencesResponse struct {
25
+
ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"`
26
+
SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers"`
27
+
LabelPreferences []LabelPreference `json:"labelPreferences"`
28
}
29
30
func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) {
···
45
json.Unmarshal([]byte(*prefs.ExternalLinkSkippedHostnames), &hostnames)
46
}
47
48
+
var labelers []LabelerSubscription
49
+
if prefs != nil && prefs.SubscribedLabelers != nil {
50
+
json.Unmarshal([]byte(*prefs.SubscribedLabelers), &labelers)
51
+
}
52
+
if labelers == nil {
53
+
labelers = []LabelerSubscription{}
54
+
serviceDID := config.Get().ServiceDID
55
+
if serviceDID != "" {
56
+
labelers = append(labelers, LabelerSubscription{DID: serviceDID})
57
+
}
58
+
}
59
+
60
+
var labelPrefs []LabelPreference
61
+
if prefs != nil && prefs.LabelPreferences != nil {
62
+
json.Unmarshal([]byte(*prefs.LabelPreferences), &labelPrefs)
63
+
}
64
+
if labelPrefs == nil {
65
+
labelPrefs = []LabelPreference{}
66
+
}
67
+
68
w.Header().Set("Content-Type", "application/json")
69
json.NewEncoder(w).Encode(PreferencesResponse{
70
ExternalLinkSkippedHostnames: hostnames,
71
+
SubscribedLabelers: labelers,
72
+
LabelPreferences: labelPrefs,
73
})
74
}
75
···
86
return
87
}
88
89
+
var xrpcLabelers []xrpc.LabelerSubscription
90
+
for _, l := range input.SubscribedLabelers {
91
+
xrpcLabelers = append(xrpcLabelers, xrpc.LabelerSubscription{DID: l.DID})
92
+
}
93
+
var xrpcLabelPrefs []xrpc.LabelPreference
94
+
for _, lp := range input.LabelPreferences {
95
+
xrpcLabelPrefs = append(xrpcLabelPrefs, xrpc.LabelPreference{
96
+
LabelerDID: lp.LabelerDID,
97
+
Label: lp.Label,
98
+
Visibility: lp.Visibility,
99
+
})
100
+
}
101
+
102
+
record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs)
103
if err := record.Validate(); err != nil {
104
http.Error(w, fmt.Sprintf("Invalid record: %v", err), http.StatusBadRequest)
105
return
106
}
107
108
+
err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
109
+
_, err := client.PutRecord(r.Context(), did, xrpc.CollectionPreferences, "self", record)
110
+
return err
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
111
})
112
113
if err != nil {
114
+
fmt.Printf("[UpdatePreferences] PDS write failed: %v\n", err)
115
http.Error(w, fmt.Sprintf("Failed to update preferences: %v", err), http.StatusInternalServerError)
116
return
117
}
···
119
createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt)
120
hostnamesJSON, _ := json.Marshal(input.ExternalLinkSkippedHostnames)
121
hostnamesStr := string(hostnamesJSON)
122
+
123
+
var subscribedLabelersPtr, labelPrefsPtr *string
124
+
if len(input.SubscribedLabelers) > 0 {
125
+
labelersJSON, _ := json.Marshal(input.SubscribedLabelers)
126
+
s := string(labelersJSON)
127
+
subscribedLabelersPtr = &s
128
+
}
129
+
if len(input.LabelPreferences) > 0 {
130
+
prefsJSON, _ := json.Marshal(input.LabelPreferences)
131
+
s := string(prefsJSON)
132
+
labelPrefsPtr = &s
133
+
}
134
+
135
uri := fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionPreferences)
136
137
err = h.db.UpsertPreferences(&db.Preferences{
138
URI: uri,
139
AuthorDID: session.DID,
140
ExternalLinkSkippedHostnames: &hostnamesStr,
141
+
SubscribedLabelers: subscribedLabelersPtr,
142
+
LabelPreferences: labelPrefsPtr,
143
CreatedAt: createdAt,
144
IndexedAt: time.Now(),
145
})
+44
backend/internal/api/profile.go
···
11
12
"github.com/go-chi/chi/v5"
13
0
14
"margin.at/internal/db"
15
"margin.at/internal/xrpc"
16
)
···
147
Links []string `json:"links"`
148
CreatedAt string `json:"createdAt"`
149
IndexedAt string `json:"indexedAt"`
0
0
0
0
0
0
0
0
0
150
}{
151
URI: profile.URI,
152
DID: profile.AuthorDID,
···
176
}
177
if resp.Links == nil {
178
resp.Links = []string{}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
179
}
180
181
w.Header().Set("Content-Type", "application/json")
···
11
12
"github.com/go-chi/chi/v5"
13
14
+
"margin.at/internal/config"
15
"margin.at/internal/db"
16
"margin.at/internal/xrpc"
17
)
···
148
Links []string `json:"links"`
149
CreatedAt string `json:"createdAt"`
150
IndexedAt string `json:"indexedAt"`
151
+
Labels []struct {
152
+
Val string `json:"val"`
153
+
Src string `json:"src"`
154
+
} `json:"labels,omitempty"`
155
+
Viewer *struct {
156
+
Blocking bool `json:"blocking"`
157
+
Muting bool `json:"muting"`
158
+
BlockedBy bool `json:"blockedBy"`
159
+
} `json:"viewer,omitempty"`
160
}{
161
URI: profile.URI,
162
DID: profile.AuthorDID,
···
186
}
187
if resp.Links == nil {
188
resp.Links = []string{}
189
+
}
190
+
191
+
viewerDID := h.getViewerDID(r)
192
+
if viewerDID != "" && viewerDID != profile.AuthorDID {
193
+
blocking, muting, blockedBy, err := h.db.GetViewerRelationship(viewerDID, profile.AuthorDID)
194
+
if err == nil {
195
+
resp.Viewer = &struct {
196
+
Blocking bool `json:"blocking"`
197
+
Muting bool `json:"muting"`
198
+
BlockedBy bool `json:"blockedBy"`
199
+
}{
200
+
Blocking: blocking,
201
+
Muting: muting,
202
+
BlockedBy: blockedBy,
203
+
}
204
+
}
205
+
}
206
+
207
+
subscribedLabelers := getSubscribedLabelers(h.db, viewerDID)
208
+
if subscribedLabelers == nil {
209
+
serviceDID := config.Get().ServiceDID
210
+
if serviceDID != "" {
211
+
subscribedLabelers = []string{serviceDID}
212
+
}
213
+
}
214
+
if didLabels, err := h.db.GetContentLabelsForDIDs([]string{profile.AuthorDID}, subscribedLabelers); err == nil {
215
+
if labels, ok := didLabels[profile.AuthorDID]; ok {
216
+
for _, l := range labels {
217
+
resp.Labels = append(resp.Labels, struct {
218
+
Val string `json:"val"`
219
+
Src string `json:"src"`
220
+
}{Val: l.Val, Src: l.Src})
221
+
}
222
+
}
223
}
224
225
w.Header().Set("Content-Type", "application/json")
+23
backend/internal/config/config.go
···
2
3
import (
4
"os"
0
5
"sync"
6
)
7
···
9
BskyPublicAPI string
10
PLCDirectory string
11
BaseURL string
0
0
12
}
13
14
var (
···
18
19
func Get() *Config {
20
once.Do(func() {
0
0
0
0
0
0
0
0
0
21
instance = &Config{
22
BskyPublicAPI: getEnvOrDefault("BSKY_PUBLIC_API", "https://public.api.bsky.app"),
23
PLCDirectory: getEnvOrDefault("PLC_DIRECTORY_URL", "https://plc.directory"),
24
BaseURL: os.Getenv("BASE_URL"),
0
0
25
}
26
})
27
return instance
···
45
func (c *Config) PLCResolveURL(did string) string {
46
return c.PLCDirectory + "/" + did
47
}
0
0
0
0
0
0
0
0
0
···
2
3
import (
4
"os"
5
+
"strings"
6
"sync"
7
)
8
···
10
BskyPublicAPI string
11
PLCDirectory string
12
BaseURL string
13
+
AdminDIDs []string
14
+
ServiceDID string
15
}
16
17
var (
···
21
22
func Get() *Config {
23
once.Do(func() {
24
+
adminDIDs := []string{}
25
+
if raw := os.Getenv("ADMIN_DIDS"); raw != "" {
26
+
for _, did := range strings.Split(raw, ",") {
27
+
did = strings.TrimSpace(did)
28
+
if did != "" {
29
+
adminDIDs = append(adminDIDs, did)
30
+
}
31
+
}
32
+
}
33
instance = &Config{
34
BskyPublicAPI: getEnvOrDefault("BSKY_PUBLIC_API", "https://public.api.bsky.app"),
35
PLCDirectory: getEnvOrDefault("PLC_DIRECTORY_URL", "https://plc.directory"),
36
BaseURL: os.Getenv("BASE_URL"),
37
+
AdminDIDs: adminDIDs,
38
+
ServiceDID: os.Getenv("SERVICE_DID"),
39
}
40
})
41
return instance
···
59
func (c *Config) PLCResolveURL(did string) string {
60
return c.PLCDirectory + "/" + did
61
}
62
+
63
+
func (c *Config) IsAdmin(did string) bool {
64
+
for _, adminDID := range c.AdminDIDs {
65
+
if adminDID == did {
66
+
return true
67
+
}
68
+
}
69
+
return false
70
+
}
+217
-5
backend/internal/db/db.go
···
149
URI string `json:"uri"`
150
AuthorDID string `json:"authorDid"`
151
ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"`
0
0
152
CreatedAt time.Time `json:"createdAt"`
153
IndexedAt time.Time `json:"indexedAt"`
154
CID *string `json:"cid,omitempty"`
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
155
}
156
157
func New(dsn string) (*DB, error) {
···
388
updated_at ` + dateType + ` NOT NULL
389
)`)
390
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
391
db.runMigrations()
392
393
return nil
···
512
513
func (db *DB) GetPreferences(did string) (*Preferences, error) {
514
var p Preferences
515
-
err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan(
516
-
&p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.CreatedAt, &p.IndexedAt, &p.CID,
517
)
518
if err == sql.ErrNoRows {
519
return nil, nil
···
526
527
func (db *DB) UpsertPreferences(p *Preferences) error {
528
query := `
529
-
INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, created_at, indexed_at, cid)
530
-
VALUES ($1, $2, $3, $4, $5, $6)
531
ON CONFLICT(uri) DO UPDATE SET
532
external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames,
0
0
533
indexed_at = EXCLUDED.indexed_at,
534
cid = EXCLUDED.cid
535
`
536
-
_, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.CreatedAt, p.IndexedAt, p.CID)
537
return err
538
}
539
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
540
func (db *DB) runMigrations() {
541
dateType := "DATETIME"
542
if db.driver == "postgres" {
···
572
db.Exec(`ALTER TABLE api_keys ADD COLUMN uri TEXT`)
573
db.Exec(`ALTER TABLE api_keys ADD COLUMN cid TEXT`)
574
db.Exec(`ALTER TABLE api_keys ADD COLUMN indexed_at ` + dateType + ` DEFAULT CURRENT_TIMESTAMP`)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
575
}
576
577
func (db *DB) Close() error {
···
149
URI string `json:"uri"`
150
AuthorDID string `json:"authorDid"`
151
ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"`
152
+
SubscribedLabelers *string `json:"subscribedLabelers,omitempty"`
153
+
LabelPreferences *string `json:"labelPreferences,omitempty"`
154
CreatedAt time.Time `json:"createdAt"`
155
IndexedAt time.Time `json:"indexedAt"`
156
CID *string `json:"cid,omitempty"`
157
+
}
158
+
159
+
type Block struct {
160
+
ID int `json:"id"`
161
+
ActorDID string `json:"actorDid"`
162
+
SubjectDID string `json:"subjectDid"`
163
+
CreatedAt time.Time `json:"createdAt"`
164
+
}
165
+
166
+
type Mute struct {
167
+
ID int `json:"id"`
168
+
ActorDID string `json:"actorDid"`
169
+
SubjectDID string `json:"subjectDid"`
170
+
CreatedAt time.Time `json:"createdAt"`
171
+
}
172
+
173
+
type ModerationReport struct {
174
+
ID int `json:"id"`
175
+
ReporterDID string `json:"reporterDid"`
176
+
SubjectDID string `json:"subjectDid"`
177
+
SubjectURI *string `json:"subjectUri,omitempty"`
178
+
ReasonType string `json:"reasonType"`
179
+
ReasonText *string `json:"reasonText,omitempty"`
180
+
Status string `json:"status"`
181
+
CreatedAt time.Time `json:"createdAt"`
182
+
ResolvedAt *time.Time `json:"resolvedAt,omitempty"`
183
+
ResolvedBy *string `json:"resolvedBy,omitempty"`
184
+
}
185
+
186
+
type ModerationAction struct {
187
+
ID int `json:"id"`
188
+
ReportID int `json:"reportId"`
189
+
ActorDID string `json:"actorDid"`
190
+
Action string `json:"action"`
191
+
Comment *string `json:"comment,omitempty"`
192
+
CreatedAt time.Time `json:"createdAt"`
193
+
}
194
+
195
+
type ContentLabel struct {
196
+
ID int `json:"id"`
197
+
Src string `json:"src"`
198
+
URI string `json:"uri"`
199
+
Val string `json:"val"`
200
+
Neg bool `json:"neg"`
201
+
CreatedBy string `json:"createdBy"`
202
+
CreatedAt time.Time `json:"createdAt"`
203
}
204
205
func New(dsn string) (*DB, error) {
···
436
updated_at ` + dateType + ` NOT NULL
437
)`)
438
439
+
db.Exec(`CREATE TABLE IF NOT EXISTS blocks (
440
+
id ` + autoInc + `,
441
+
actor_did TEXT NOT NULL,
442
+
subject_did TEXT NOT NULL,
443
+
created_at ` + dateType + ` NOT NULL,
444
+
UNIQUE(actor_did, subject_did)
445
+
)`)
446
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_actor ON blocks(actor_did)`)
447
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_subject ON blocks(subject_did)`)
448
+
449
+
db.Exec(`CREATE TABLE IF NOT EXISTS mutes (
450
+
id ` + autoInc + `,
451
+
actor_did TEXT NOT NULL,
452
+
subject_did TEXT NOT NULL,
453
+
created_at ` + dateType + ` NOT NULL,
454
+
UNIQUE(actor_did, subject_did)
455
+
)`)
456
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_actor ON mutes(actor_did)`)
457
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_subject ON mutes(subject_did)`)
458
+
459
+
db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports (
460
+
id ` + autoInc + `,
461
+
reporter_did TEXT NOT NULL,
462
+
subject_did TEXT NOT NULL,
463
+
subject_uri TEXT,
464
+
reason_type TEXT NOT NULL,
465
+
reason_text TEXT,
466
+
status TEXT NOT NULL DEFAULT 'pending',
467
+
created_at ` + dateType + ` NOT NULL,
468
+
resolved_at ` + dateType + `,
469
+
resolved_by TEXT
470
+
)`)
471
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`)
472
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`)
473
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`)
474
+
475
+
db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions (
476
+
id ` + autoInc + `,
477
+
report_id INTEGER NOT NULL,
478
+
actor_did TEXT NOT NULL,
479
+
action TEXT NOT NULL,
480
+
comment TEXT,
481
+
created_at ` + dateType + ` NOT NULL
482
+
)`)
483
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`)
484
+
485
+
db.Exec(`CREATE TABLE IF NOT EXISTS content_labels (
486
+
id ` + autoInc + `,
487
+
src TEXT NOT NULL,
488
+
uri TEXT NOT NULL,
489
+
val TEXT NOT NULL,
490
+
neg INTEGER NOT NULL DEFAULT 0,
491
+
created_by TEXT NOT NULL,
492
+
created_at ` + dateType + ` NOT NULL
493
+
)`)
494
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`)
495
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`)
496
+
497
db.runMigrations()
498
499
return nil
···
618
619
func (db *DB) GetPreferences(did string) (*Preferences, error) {
620
var p Preferences
621
+
err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan(
622
+
&p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, &p.LabelPreferences, &p.CreatedAt, &p.IndexedAt, &p.CID,
623
)
624
if err == sql.ErrNoRows {
625
return nil, nil
···
632
633
func (db *DB) UpsertPreferences(p *Preferences) error {
634
query := `
635
+
INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, created_at, indexed_at, cid)
636
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
637
ON CONFLICT(uri) DO UPDATE SET
638
external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames,
639
+
subscribed_labelers = EXCLUDED.subscribed_labelers,
640
+
label_preferences = EXCLUDED.label_preferences,
641
indexed_at = EXCLUDED.indexed_at,
642
cid = EXCLUDED.cid
643
`
644
+
_, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, p.LabelPreferences, p.CreatedAt, p.IndexedAt, p.CID)
645
return err
646
}
647
648
+
func (db *DB) DeleteAPIKeyByURI(uri string) error {
649
+
_, err := db.Exec("DELETE FROM api_keys WHERE uri = $1", uri)
650
+
return err
651
+
}
652
+
653
+
func (db *DB) DeletePreferences(uri string) error {
654
+
_, err := db.Exec("DELETE FROM preferences WHERE uri = $1", uri)
655
+
return err
656
+
}
657
+
658
+
func (db *DB) GetAPIKeyURIs(ownerDID string) ([]string, error) {
659
+
rows, err := db.Query(db.Rebind("SELECT uri FROM api_keys WHERE owner_did = ? AND uri IS NOT NULL AND uri != ''"), ownerDID)
660
+
if err != nil {
661
+
return nil, err
662
+
}
663
+
defer rows.Close()
664
+
var uris []string
665
+
for rows.Next() {
666
+
var uri string
667
+
if err := rows.Scan(&uri); err != nil {
668
+
return nil, err
669
+
}
670
+
uris = append(uris, uri)
671
+
}
672
+
return uris, nil
673
+
}
674
+
675
+
func (db *DB) GetPreferenceURIs(did string) ([]string, error) {
676
+
rows, err := db.Query(db.Rebind("SELECT uri FROM preferences WHERE author_did = ? AND uri IS NOT NULL AND uri != ''"), did)
677
+
if err != nil {
678
+
return nil, err
679
+
}
680
+
defer rows.Close()
681
+
var uris []string
682
+
for rows.Next() {
683
+
var uri string
684
+
if err := rows.Scan(&uri); err != nil {
685
+
return nil, err
686
+
}
687
+
uris = append(uris, uri)
688
+
}
689
+
return uris, nil
690
+
}
691
+
692
func (db *DB) runMigrations() {
693
dateType := "DATETIME"
694
if db.driver == "postgres" {
···
724
db.Exec(`ALTER TABLE api_keys ADD COLUMN uri TEXT`)
725
db.Exec(`ALTER TABLE api_keys ADD COLUMN cid TEXT`)
726
db.Exec(`ALTER TABLE api_keys ADD COLUMN indexed_at ` + dateType + ` DEFAULT CURRENT_TIMESTAMP`)
727
+
728
+
db.migrateModeration(dateType)
729
+
730
+
db.Exec(`ALTER TABLE preferences ADD COLUMN subscribed_labelers TEXT`)
731
+
db.Exec(`ALTER TABLE preferences ADD COLUMN label_preferences TEXT`)
732
+
}
733
+
734
+
func (db *DB) migrateModeration(dateType string) {
735
+
_, err := db.Exec(`SELECT subject_did FROM moderation_reports LIMIT 0`)
736
+
if err != nil {
737
+
db.Exec(`DROP TABLE IF EXISTS moderation_reports`)
738
+
db.Exec(`DROP TABLE IF EXISTS moderation_actions`)
739
+
740
+
autoInc := "INTEGER PRIMARY KEY AUTOINCREMENT"
741
+
if db.driver == "postgres" {
742
+
autoInc = "SERIAL PRIMARY KEY"
743
+
}
744
+
745
+
db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports (
746
+
id ` + autoInc + `,
747
+
reporter_did TEXT NOT NULL,
748
+
subject_did TEXT NOT NULL,
749
+
subject_uri TEXT,
750
+
reason_type TEXT NOT NULL,
751
+
reason_text TEXT,
752
+
status TEXT NOT NULL DEFAULT 'pending',
753
+
created_at ` + dateType + ` NOT NULL,
754
+
resolved_at ` + dateType + `,
755
+
resolved_by TEXT
756
+
)`)
757
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`)
758
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`)
759
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`)
760
+
761
+
db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions (
762
+
id ` + autoInc + `,
763
+
report_id INTEGER NOT NULL,
764
+
actor_did TEXT NOT NULL,
765
+
action TEXT NOT NULL,
766
+
comment TEXT,
767
+
created_at ` + dateType + ` NOT NULL
768
+
)`)
769
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`)
770
+
}
771
+
772
+
autoInc := "INTEGER PRIMARY KEY AUTOINCREMENT"
773
+
if db.driver == "postgres" {
774
+
autoInc = "SERIAL PRIMARY KEY"
775
+
}
776
+
db.Exec(`CREATE TABLE IF NOT EXISTS content_labels (
777
+
id ` + autoInc + `,
778
+
src TEXT NOT NULL,
779
+
uri TEXT NOT NULL,
780
+
val TEXT NOT NULL,
781
+
neg INTEGER NOT NULL DEFAULT 0,
782
+
created_by TEXT NOT NULL,
783
+
created_at ` + dateType + ` NOT NULL
784
+
)`)
785
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`)
786
+
db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`)
787
}
788
789
func (db *DB) Close() error {
+5
backend/internal/db/queries_keys.go
···
8
_, err := db.Exec(db.Rebind(`
9
INSERT INTO api_keys (id, owner_did, name, key_hash, created_at, uri, cid)
10
VALUES (?, ?, ?, ?, ?, ?, ?)
0
0
0
0
0
11
`), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt, key.URI, key.CID)
12
return err
13
}
···
8
_, err := db.Exec(db.Rebind(`
9
INSERT INTO api_keys (id, owner_did, name, key_hash, created_at, uri, cid)
10
VALUES (?, ?, ?, ?, ?, ?, ?)
11
+
ON CONFLICT (id) DO UPDATE SET
12
+
name = EXCLUDED.name,
13
+
key_hash = EXCLUDED.key_hash,
14
+
uri = EXCLUDED.uri,
15
+
cid = EXCLUDED.cid
16
`), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt, key.URI, key.CID)
17
return err
18
}
+430
backend/internal/db/queries_moderation.go
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
package db
2
+
3
+
import "time"
4
+
5
+
6
+
func (db *DB) CreateBlock(actorDID, subjectDID string) error {
7
+
query := `INSERT INTO blocks (actor_did, subject_did, created_at) VALUES (?, ?, ?)
8
+
ON CONFLICT(actor_did, subject_did) DO NOTHING`
9
+
_, err := db.Exec(db.Rebind(query), actorDID, subjectDID, time.Now())
10
+
return err
11
+
}
12
+
13
+
func (db *DB) DeleteBlock(actorDID, subjectDID string) error {
14
+
_, err := db.Exec(db.Rebind(`DELETE FROM blocks WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID)
15
+
return err
16
+
}
17
+
18
+
func (db *DB) GetBlocks(actorDID string) ([]Block, error) {
19
+
rows, err := db.Query(db.Rebind(`SELECT id, actor_did, subject_did, created_at FROM blocks WHERE actor_did = ? ORDER BY created_at DESC`), actorDID)
20
+
if err != nil {
21
+
return nil, err
22
+
}
23
+
defer rows.Close()
24
+
25
+
var blocks []Block
26
+
for rows.Next() {
27
+
var b Block
28
+
if err := rows.Scan(&b.ID, &b.ActorDID, &b.SubjectDID, &b.CreatedAt); err != nil {
29
+
continue
30
+
}
31
+
blocks = append(blocks, b)
32
+
}
33
+
return blocks, nil
34
+
}
35
+
36
+
func (db *DB) IsBlocked(actorDID, subjectDID string) (bool, error) {
37
+
var count int
38
+
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM blocks WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID).Scan(&count)
39
+
return count > 0, err
40
+
}
41
+
42
+
func (db *DB) IsBlockedEither(did1, did2 string) (bool, error) {
43
+
var count int
44
+
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM blocks WHERE (actor_did = ? AND subject_did = ?) OR (actor_did = ? AND subject_did = ?)`), did1, did2, did2, did1).Scan(&count)
45
+
return count > 0, err
46
+
}
47
+
48
+
func (db *DB) GetBlockedDIDs(actorDID string) ([]string, error) {
49
+
rows, err := db.Query(db.Rebind(`SELECT subject_did FROM blocks WHERE actor_did = ?`), actorDID)
50
+
if err != nil {
51
+
return nil, err
52
+
}
53
+
defer rows.Close()
54
+
55
+
var dids []string
56
+
for rows.Next() {
57
+
var did string
58
+
if err := rows.Scan(&did); err != nil {
59
+
continue
60
+
}
61
+
dids = append(dids, did)
62
+
}
63
+
return dids, nil
64
+
}
65
+
66
+
func (db *DB) GetBlockedByDIDs(actorDID string) ([]string, error) {
67
+
rows, err := db.Query(db.Rebind(`SELECT actor_did FROM blocks WHERE subject_did = ?`), actorDID)
68
+
if err != nil {
69
+
return nil, err
70
+
}
71
+
defer rows.Close()
72
+
73
+
var dids []string
74
+
for rows.Next() {
75
+
var did string
76
+
if err := rows.Scan(&did); err != nil {
77
+
continue
78
+
}
79
+
dids = append(dids, did)
80
+
}
81
+
return dids, nil
82
+
}
83
+
84
+
85
+
func (db *DB) CreateMute(actorDID, subjectDID string) error {
86
+
query := `INSERT INTO mutes (actor_did, subject_did, created_at) VALUES (?, ?, ?)
87
+
ON CONFLICT(actor_did, subject_did) DO NOTHING`
88
+
_, err := db.Exec(db.Rebind(query), actorDID, subjectDID, time.Now())
89
+
return err
90
+
}
91
+
92
+
func (db *DB) DeleteMute(actorDID, subjectDID string) error {
93
+
_, err := db.Exec(db.Rebind(`DELETE FROM mutes WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID)
94
+
return err
95
+
}
96
+
97
+
func (db *DB) GetMutes(actorDID string) ([]Mute, error) {
98
+
rows, err := db.Query(db.Rebind(`SELECT id, actor_did, subject_did, created_at FROM mutes WHERE actor_did = ? ORDER BY created_at DESC`), actorDID)
99
+
if err != nil {
100
+
return nil, err
101
+
}
102
+
defer rows.Close()
103
+
104
+
var mutes []Mute
105
+
for rows.Next() {
106
+
var m Mute
107
+
if err := rows.Scan(&m.ID, &m.ActorDID, &m.SubjectDID, &m.CreatedAt); err != nil {
108
+
continue
109
+
}
110
+
mutes = append(mutes, m)
111
+
}
112
+
return mutes, nil
113
+
}
114
+
115
+
func (db *DB) IsMuted(actorDID, subjectDID string) (bool, error) {
116
+
var count int
117
+
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM mutes WHERE actor_did = ? AND subject_did = ?`), actorDID, subjectDID).Scan(&count)
118
+
return count > 0, err
119
+
}
120
+
121
+
func (db *DB) GetMutedDIDs(actorDID string) ([]string, error) {
122
+
rows, err := db.Query(db.Rebind(`SELECT subject_did FROM mutes WHERE actor_did = ?`), actorDID)
123
+
if err != nil {
124
+
return nil, err
125
+
}
126
+
defer rows.Close()
127
+
128
+
var dids []string
129
+
for rows.Next() {
130
+
var did string
131
+
if err := rows.Scan(&did); err != nil {
132
+
continue
133
+
}
134
+
dids = append(dids, did)
135
+
}
136
+
return dids, nil
137
+
}
138
+
139
+
func (db *DB) GetAllHiddenDIDs(actorDID string) (map[string]bool, error) {
140
+
hidden := make(map[string]bool)
141
+
if actorDID == "" {
142
+
return hidden, nil
143
+
}
144
+
145
+
blocked, err := db.GetBlockedDIDs(actorDID)
146
+
if err != nil {
147
+
return hidden, err
148
+
}
149
+
for _, did := range blocked {
150
+
hidden[did] = true
151
+
}
152
+
153
+
blockedBy, err := db.GetBlockedByDIDs(actorDID)
154
+
if err != nil {
155
+
return hidden, err
156
+
}
157
+
for _, did := range blockedBy {
158
+
hidden[did] = true
159
+
}
160
+
161
+
muted, err := db.GetMutedDIDs(actorDID)
162
+
if err != nil {
163
+
return hidden, err
164
+
}
165
+
for _, did := range muted {
166
+
hidden[did] = true
167
+
}
168
+
169
+
return hidden, nil
170
+
}
171
+
172
+
func (db *DB) GetViewerRelationship(viewerDID, subjectDID string) (blocked bool, muted bool, blockedBy bool, err error) {
173
+
if viewerDID == "" || subjectDID == "" {
174
+
return false, false, false, nil
175
+
}
176
+
177
+
blocked, err = db.IsBlocked(viewerDID, subjectDID)
178
+
if err != nil {
179
+
return
180
+
}
181
+
182
+
muted, err = db.IsMuted(viewerDID, subjectDID)
183
+
if err != nil {
184
+
return
185
+
}
186
+
187
+
blockedBy, err = db.IsBlocked(subjectDID, viewerDID)
188
+
return
189
+
}
190
+
191
+
192
+
func (db *DB) CreateReport(reporterDID, subjectDID string, subjectURI *string, reasonType string, reasonText *string) (int, error) {
193
+
query := `INSERT INTO moderation_reports (reporter_did, subject_did, subject_uri, reason_type, reason_text, status, created_at)
194
+
VALUES (?, ?, ?, ?, ?, 'pending', ?)`
195
+
196
+
result, err := db.Exec(db.Rebind(query), reporterDID, subjectDID, subjectURI, reasonType, reasonText, time.Now())
197
+
if err != nil {
198
+
return 0, err
199
+
}
200
+
201
+
id, err := result.LastInsertId()
202
+
return int(id), err
203
+
}
204
+
205
+
func (db *DB) GetReports(status string, limit, offset int) ([]ModerationReport, error) {
206
+
query := `SELECT id, reporter_did, subject_did, subject_uri, reason_type, reason_text, status, created_at, resolved_at, resolved_by
207
+
FROM moderation_reports`
208
+
args := []interface{}{}
209
+
210
+
if status != "" {
211
+
query += ` WHERE status = ?`
212
+
args = append(args, status)
213
+
}
214
+
215
+
query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
216
+
args = append(args, limit, offset)
217
+
218
+
rows, err := db.Query(db.Rebind(query), args...)
219
+
if err != nil {
220
+
return nil, err
221
+
}
222
+
defer rows.Close()
223
+
224
+
var reports []ModerationReport
225
+
for rows.Next() {
226
+
var r ModerationReport
227
+
if err := rows.Scan(&r.ID, &r.ReporterDID, &r.SubjectDID, &r.SubjectURI, &r.ReasonType, &r.ReasonText, &r.Status, &r.CreatedAt, &r.ResolvedAt, &r.ResolvedBy); err != nil {
228
+
continue
229
+
}
230
+
reports = append(reports, r)
231
+
}
232
+
return reports, nil
233
+
}
234
+
235
+
func (db *DB) GetReport(id int) (*ModerationReport, error) {
236
+
var r ModerationReport
237
+
err := db.QueryRow(db.Rebind(`SELECT id, reporter_did, subject_did, subject_uri, reason_type, reason_text, status, created_at, resolved_at, resolved_by FROM moderation_reports WHERE id = ?`), id).Scan(
238
+
&r.ID, &r.ReporterDID, &r.SubjectDID, &r.SubjectURI, &r.ReasonType, &r.ReasonText, &r.Status, &r.CreatedAt, &r.ResolvedAt, &r.ResolvedBy,
239
+
)
240
+
if err != nil {
241
+
return nil, err
242
+
}
243
+
return &r, nil
244
+
}
245
+
246
+
func (db *DB) ResolveReport(id int, resolvedBy string, status string) error {
247
+
_, err := db.Exec(db.Rebind(`UPDATE moderation_reports SET status = ?, resolved_at = ?, resolved_by = ? WHERE id = ?`), status, time.Now(), resolvedBy, id)
248
+
return err
249
+
}
250
+
251
+
func (db *DB) CreateModerationAction(reportID int, actorDID, action string, comment *string) error {
252
+
query := `INSERT INTO moderation_actions (report_id, actor_did, action, comment, created_at) VALUES (?, ?, ?, ?, ?)`
253
+
_, err := db.Exec(db.Rebind(query), reportID, actorDID, action, comment, time.Now())
254
+
return err
255
+
}
256
+
257
+
func (db *DB) GetReportActions(reportID int) ([]ModerationAction, error) {
258
+
rows, err := db.Query(db.Rebind(`SELECT id, report_id, actor_did, action, comment, created_at FROM moderation_actions WHERE report_id = ? ORDER BY created_at DESC`), reportID)
259
+
if err != nil {
260
+
return nil, err
261
+
}
262
+
defer rows.Close()
263
+
264
+
var actions []ModerationAction
265
+
for rows.Next() {
266
+
var a ModerationAction
267
+
if err := rows.Scan(&a.ID, &a.ReportID, &a.ActorDID, &a.Action, &a.Comment, &a.CreatedAt); err != nil {
268
+
continue
269
+
}
270
+
actions = append(actions, a)
271
+
}
272
+
return actions, nil
273
+
}
274
+
275
+
func (db *DB) GetReportCount(status string) (int, error) {
276
+
query := `SELECT COUNT(*) FROM moderation_reports`
277
+
args := []interface{}{}
278
+
if status != "" {
279
+
query += ` WHERE status = ?`
280
+
args = append(args, status)
281
+
}
282
+
var count int
283
+
err := db.QueryRow(db.Rebind(query), args...).Scan(&count)
284
+
return count, err
285
+
}
286
+
287
+
288
+
func (db *DB) CreateContentLabel(src, uri, val, createdBy string) error {
289
+
query := `INSERT INTO content_labels (src, uri, val, neg, created_by, created_at) VALUES (?, ?, ?, 0, ?, ?)`
290
+
_, err := db.Exec(db.Rebind(query), src, uri, val, createdBy, time.Now())
291
+
return err
292
+
}
293
+
294
+
func (db *DB) SyncSelfLabels(authorDID, uri string, labels []string) error {
295
+
_, err := db.Exec(db.Rebind(`DELETE FROM content_labels WHERE src = ? AND uri = ? AND created_by = ?`), authorDID, uri, authorDID)
296
+
if err != nil {
297
+
return err
298
+
}
299
+
for _, val := range labels {
300
+
if err := db.CreateContentLabel(authorDID, uri, val, authorDID); err != nil {
301
+
return err
302
+
}
303
+
}
304
+
return nil
305
+
}
306
+
307
+
func (db *DB) NegateContentLabel(id int) error {
308
+
_, err := db.Exec(db.Rebind(`UPDATE content_labels SET neg = 1 WHERE id = ?`), id)
309
+
return err
310
+
}
311
+
312
+
func (db *DB) DeleteContentLabel(id int) error {
313
+
_, err := db.Exec(db.Rebind(`DELETE FROM content_labels WHERE id = ?`), id)
314
+
return err
315
+
}
316
+
317
+
func (db *DB) GetContentLabelsForURIs(uris []string, labelerDIDs []string) (map[string][]ContentLabel, error) {
318
+
result := make(map[string][]ContentLabel)
319
+
if len(uris) == 0 {
320
+
return result, nil
321
+
}
322
+
323
+
placeholders := make([]string, len(uris))
324
+
args := make([]interface{}, len(uris))
325
+
for i, uri := range uris {
326
+
placeholders[i] = "?"
327
+
args[i] = uri
328
+
}
329
+
330
+
query := `SELECT id, src, uri, val, neg, created_by, created_at FROM content_labels
331
+
WHERE uri IN (` + joinStrings(placeholders, ",") + `) AND neg = 0`
332
+
333
+
if len(labelerDIDs) > 0 {
334
+
srcPlaceholders := make([]string, len(labelerDIDs))
335
+
for i, did := range labelerDIDs {
336
+
srcPlaceholders[i] = "?"
337
+
args = append(args, did)
338
+
}
339
+
query += ` AND src IN (` + joinStrings(srcPlaceholders, ",") + `)`
340
+
}
341
+
342
+
query += ` ORDER BY created_at DESC`
343
+
344
+
rows, err := db.Query(db.Rebind(query), args...)
345
+
if err != nil {
346
+
return result, err
347
+
}
348
+
defer rows.Close()
349
+
350
+
for rows.Next() {
351
+
var l ContentLabel
352
+
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil {
353
+
continue
354
+
}
355
+
result[l.URI] = append(result[l.URI], l)
356
+
}
357
+
return result, nil
358
+
}
359
+
360
+
func (db *DB) GetContentLabelsForDIDs(dids []string, labelerDIDs []string) (map[string][]ContentLabel, error) {
361
+
result := make(map[string][]ContentLabel)
362
+
if len(dids) == 0 {
363
+
return result, nil
364
+
}
365
+
366
+
placeholders := make([]string, len(dids))
367
+
args := make([]interface{}, len(dids))
368
+
for i, did := range dids {
369
+
placeholders[i] = "?"
370
+
args[i] = did
371
+
}
372
+
373
+
query := `SELECT id, src, uri, val, neg, created_by, created_at FROM content_labels
374
+
WHERE uri IN (` + joinStrings(placeholders, ",") + `) AND neg = 0`
375
+
376
+
if len(labelerDIDs) > 0 {
377
+
srcPlaceholders := make([]string, len(labelerDIDs))
378
+
for i, did := range labelerDIDs {
379
+
srcPlaceholders[i] = "?"
380
+
args = append(args, did)
381
+
}
382
+
query += ` AND src IN (` + joinStrings(srcPlaceholders, ",") + `)`
383
+
}
384
+
385
+
query += ` ORDER BY created_at DESC`
386
+
387
+
rows, err := db.Query(db.Rebind(query), args...)
388
+
if err != nil {
389
+
return result, err
390
+
}
391
+
defer rows.Close()
392
+
393
+
for rows.Next() {
394
+
var l ContentLabel
395
+
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil {
396
+
continue
397
+
}
398
+
result[l.URI] = append(result[l.URI], l)
399
+
}
400
+
return result, nil
401
+
}
402
+
403
+
func (db *DB) GetAllContentLabels(limit, offset int) ([]ContentLabel, error) {
404
+
rows, err := db.Query(db.Rebind(`SELECT id, src, uri, val, neg, created_by, created_at FROM content_labels ORDER BY created_at DESC LIMIT ? OFFSET ?`), limit, offset)
405
+
if err != nil {
406
+
return nil, err
407
+
}
408
+
defer rows.Close()
409
+
410
+
var labels []ContentLabel
411
+
for rows.Next() {
412
+
var l ContentLabel
413
+
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil {
414
+
continue
415
+
}
416
+
labels = append(labels, l)
417
+
}
418
+
return labels, nil
419
+
}
420
+
421
+
func joinStrings(strs []string, sep string) string {
422
+
result := ""
423
+
for i, s := range strs {
424
+
if i > 0 {
425
+
result += sep
426
+
}
427
+
result += s
428
+
}
429
+
return result
430
+
}
+114
backend/internal/firehose/ingester.go
···
27
CollectionCollection = "at.margin.collection"
28
CollectionCollectionItem = "at.margin.collectionItem"
29
CollectionProfile = "at.margin.profile"
0
0
30
CollectionSembleCard = "network.cosmik.card"
31
CollectionSembleCollection = "network.cosmik.collection"
32
)
···
64
i.RegisterHandler(CollectionCollection, i.handleCollection)
65
i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem)
66
i.RegisterHandler(CollectionProfile, i.handleProfile)
0
0
67
i.RegisterHandler(CollectionSembleCard, i.handleSembleCard)
68
i.RegisterHandler(CollectionSembleCollection, i.handleSembleCollection)
69
i.RegisterHandler(xrpc.CollectionSembleCollectionLink, i.handleSembleCollectionLink)
···
272
i.db.RemoveFromCollection(uri)
273
case CollectionProfile:
274
i.db.DeleteProfile(uri)
0
0
0
0
275
case CollectionSembleCard:
276
i.db.DeleteAnnotation(uri)
277
i.db.DeleteBookmark(uri)
···
733
log.Printf("Failed to index profile: %v", err)
734
} else {
735
log.Printf("Indexed profile from %s", event.Repo)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
736
}
737
}
738
···
27
CollectionCollection = "at.margin.collection"
28
CollectionCollectionItem = "at.margin.collectionItem"
29
CollectionProfile = "at.margin.profile"
30
+
CollectionAPIKey = "at.margin.apikey"
31
+
CollectionPreferences = "at.margin.preferences"
32
CollectionSembleCard = "network.cosmik.card"
33
CollectionSembleCollection = "network.cosmik.collection"
34
)
···
66
i.RegisterHandler(CollectionCollection, i.handleCollection)
67
i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem)
68
i.RegisterHandler(CollectionProfile, i.handleProfile)
69
+
i.RegisterHandler(CollectionAPIKey, i.handleAPIKey)
70
+
i.RegisterHandler(CollectionPreferences, i.handlePreferences)
71
i.RegisterHandler(CollectionSembleCard, i.handleSembleCard)
72
i.RegisterHandler(CollectionSembleCollection, i.handleSembleCollection)
73
i.RegisterHandler(xrpc.CollectionSembleCollectionLink, i.handleSembleCollectionLink)
···
276
i.db.RemoveFromCollection(uri)
277
case CollectionProfile:
278
i.db.DeleteProfile(uri)
279
+
case CollectionAPIKey:
280
+
i.db.DeleteAPIKeyByURI(uri)
281
+
case CollectionPreferences:
282
+
i.db.DeletePreferences(uri)
283
case CollectionSembleCard:
284
i.db.DeleteAnnotation(uri)
285
i.db.DeleteBookmark(uri)
···
741
log.Printf("Failed to index profile: %v", err)
742
} else {
743
log.Printf("Indexed profile from %s", event.Repo)
744
+
}
745
+
}
746
+
747
+
func (i *Ingester) handleAPIKey(event *FirehoseEvent) {
748
+
var record struct {
749
+
Name string `json:"name"`
750
+
KeyHash string `json:"keyHash"`
751
+
CreatedAt string `json:"createdAt"`
752
+
}
753
+
754
+
if err := json.Unmarshal(event.Record, &record); err != nil {
755
+
return
756
+
}
757
+
758
+
uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey)
759
+
760
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
761
+
if err != nil {
762
+
createdAt = time.Now()
763
+
}
764
+
765
+
var cidPtr *string
766
+
if event.CID != "" {
767
+
cidPtr = &event.CID
768
+
}
769
+
770
+
apiKey := &db.APIKey{
771
+
ID: event.Rkey,
772
+
OwnerDID: event.Repo,
773
+
Name: record.Name,
774
+
KeyHash: record.KeyHash,
775
+
CreatedAt: createdAt,
776
+
URI: uri,
777
+
CID: cidPtr,
778
+
IndexedAt: time.Now(),
779
+
}
780
+
781
+
if err := i.db.CreateAPIKey(apiKey); err != nil {
782
+
log.Printf("Failed to index API key: %v", err)
783
+
} else {
784
+
log.Printf("Indexed API key from %s: %s", event.Repo, record.Name)
785
+
}
786
+
}
787
+
788
+
func (i *Ingester) handlePreferences(event *FirehoseEvent) {
789
+
if event.Rkey != "self" {
790
+
return
791
+
}
792
+
793
+
var record struct {
794
+
ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"`
795
+
SubscribedLabelers json.RawMessage `json:"subscribedLabelers"`
796
+
LabelPreferences json.RawMessage `json:"labelPreferences"`
797
+
CreatedAt string `json:"createdAt"`
798
+
}
799
+
800
+
if err := json.Unmarshal(event.Record, &record); err != nil {
801
+
return
802
+
}
803
+
804
+
uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey)
805
+
806
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
807
+
if err != nil {
808
+
createdAt = time.Now()
809
+
}
810
+
811
+
var cidPtr *string
812
+
if event.CID != "" {
813
+
cidPtr = &event.CID
814
+
}
815
+
816
+
var skippedHostnamesPtr *string
817
+
if len(record.ExternalLinkSkippedHostnames) > 0 {
818
+
hostnamesBytes, _ := json.Marshal(record.ExternalLinkSkippedHostnames)
819
+
hostnamesStr := string(hostnamesBytes)
820
+
skippedHostnamesPtr = &hostnamesStr
821
+
}
822
+
823
+
var subscribedLabelersPtr *string
824
+
if len(record.SubscribedLabelers) > 0 && string(record.SubscribedLabelers) != "null" {
825
+
s := string(record.SubscribedLabelers)
826
+
subscribedLabelersPtr = &s
827
+
}
828
+
829
+
var labelPrefsPtr *string
830
+
if len(record.LabelPreferences) > 0 && string(record.LabelPreferences) != "null" {
831
+
s := string(record.LabelPreferences)
832
+
labelPrefsPtr = &s
833
+
}
834
+
835
+
prefs := &db.Preferences{
836
+
URI: uri,
837
+
AuthorDID: event.Repo,
838
+
ExternalLinkSkippedHostnames: skippedHostnamesPtr,
839
+
SubscribedLabelers: subscribedLabelersPtr,
840
+
LabelPreferences: labelPrefsPtr,
841
+
CreatedAt: createdAt,
842
+
IndexedAt: time.Now(),
843
+
CID: cidPtr,
844
+
}
845
+
846
+
if err := i.db.UpsertPreferences(prefs); err != nil {
847
+
log.Printf("Failed to index preferences: %v", err)
848
+
} else {
849
+
log.Printf("Indexed preferences from %s", event.Repo)
850
}
851
}
852
+73
-1
backend/internal/sync/service.go
···
34
xrpc.CollectionLike,
35
xrpc.CollectionCollection,
36
xrpc.CollectionCollectionItem,
0
0
37
xrpc.CollectionSembleCard,
38
xrpc.CollectionSembleCollection,
39
xrpc.CollectionSembleCollectionLink,
···
187
} else {
188
err = e
189
}
190
-
case xrpc.CollectionSembleCollectionLink:
0
0
0
0
0
0
191
items, e := s.db.GetCollectionItemsByAuthor(did)
192
if e == nil {
193
for _, item := range items {
···
224
_ = s.db.DeleteCollection(uri)
225
case xrpc.CollectionSembleCollectionLink:
226
_ = s.db.RemoveFromCollection(uri)
0
0
0
0
227
}
228
deletedCount++
229
}
···
612
Position: 0,
613
CreatedAt: createdAt,
614
IndexedAt: time.Now(),
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
615
})
616
}
617
return nil
···
34
xrpc.CollectionLike,
35
xrpc.CollectionCollection,
36
xrpc.CollectionCollectionItem,
37
+
xrpc.CollectionAPIKey,
38
+
xrpc.CollectionPreferences,
39
xrpc.CollectionSembleCard,
40
xrpc.CollectionSembleCollection,
41
xrpc.CollectionSembleCollectionLink,
···
189
} else {
190
err = e
191
}
192
+
case xrpc.CollectionAPIKey:
193
+
localURIs, err = s.db.GetAPIKeyURIs(did)
194
+
localURIs = filterURIsByCollection(localURIs, xrpc.CollectionAPIKey)
195
+
case xrpc.CollectionPreferences:
196
+
localURIs, err = s.db.GetPreferenceURIs(did)
197
+
localURIs = filterURIsByCollection(localURIs, xrpc.CollectionPreferences)
198
+
case xrpc.CollectionSembleCollectionLink:
199
items, e := s.db.GetCollectionItemsByAuthor(did)
200
if e == nil {
201
for _, item := range items {
···
232
_ = s.db.DeleteCollection(uri)
233
case xrpc.CollectionSembleCollectionLink:
234
_ = s.db.RemoveFromCollection(uri)
235
+
case xrpc.CollectionAPIKey:
236
+
_ = s.db.DeleteAPIKeyByURI(uri)
237
+
case xrpc.CollectionPreferences:
238
+
_ = s.db.DeletePreferences(uri)
239
}
240
deletedCount++
241
}
···
624
Position: 0,
625
CreatedAt: createdAt,
626
IndexedAt: time.Now(),
627
+
})
628
+
629
+
case xrpc.CollectionAPIKey:
630
+
var record xrpc.APIKeyRecord
631
+
if err := json.Unmarshal(value, &record); err != nil {
632
+
return err
633
+
}
634
+
createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt)
635
+
636
+
parts := strings.Split(uri, "/")
637
+
rkey := parts[len(parts)-1]
638
+
639
+
return s.db.CreateAPIKey(&db.APIKey{
640
+
ID: rkey,
641
+
OwnerDID: did,
642
+
Name: record.Name,
643
+
KeyHash: record.KeyHash,
644
+
CreatedAt: createdAt,
645
+
URI: uri,
646
+
CID: cidPtr,
647
+
IndexedAt: time.Now(),
648
+
})
649
+
650
+
case xrpc.CollectionPreferences:
651
+
var record xrpc.PreferencesRecord
652
+
if err := json.Unmarshal(value, &record); err != nil {
653
+
return err
654
+
}
655
+
createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt)
656
+
657
+
var skippedHostnamesPtr *string
658
+
if len(record.ExternalLinkSkippedHostnames) > 0 {
659
+
hostnamesBytes, _ := json.Marshal(record.ExternalLinkSkippedHostnames)
660
+
hostnamesStr := string(hostnamesBytes)
661
+
skippedHostnamesPtr = &hostnamesStr
662
+
}
663
+
664
+
var subscribedLabelersPtr *string
665
+
if len(record.SubscribedLabelers) > 0 {
666
+
labelersBytes, _ := json.Marshal(record.SubscribedLabelers)
667
+
s := string(labelersBytes)
668
+
subscribedLabelersPtr = &s
669
+
}
670
+
671
+
var labelPrefsPtr *string
672
+
if len(record.LabelPreferences) > 0 {
673
+
prefsBytes, _ := json.Marshal(record.LabelPreferences)
674
+
s := string(prefsBytes)
675
+
labelPrefsPtr = &s
676
+
}
677
+
678
+
return s.db.UpsertPreferences(&db.Preferences{
679
+
URI: uri,
680
+
AuthorDID: did,
681
+
ExternalLinkSkippedHostnames: skippedHostnamesPtr,
682
+
SubscribedLabelers: subscribedLabelersPtr,
683
+
LabelPreferences: labelPrefsPtr,
684
+
CreatedAt: createdAt,
685
+
IndexedAt: time.Now(),
686
+
CID: cidPtr,
687
})
688
}
689
return nil
+65
-5
backend/internal/xrpc/records.go
···
25
SelectorTypePosition = "TextPositionSelector"
26
)
27
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
28
type Selector struct {
29
Type string `json:"type"`
30
}
···
86
Facets []Facet `json:"facets,omitempty"`
87
Generator *Generator `json:"generator,omitempty"`
88
Rights string `json:"rights,omitempty"`
0
89
CreatedAt string `json:"createdAt"`
90
}
91
···
203
Tags []string `json:"tags,omitempty"`
204
Generator *Generator `json:"generator,omitempty"`
205
Rights string `json:"rights,omitempty"`
0
206
CreatedAt string `json:"createdAt"`
207
}
208
···
315
Tags []string `json:"tags,omitempty"`
316
Generator *Generator `json:"generator,omitempty"`
317
Rights string `json:"rights,omitempty"`
0
318
CreatedAt string `json:"createdAt"`
319
}
320
···
435
return nil
436
}
437
0
0
0
0
0
0
0
0
0
0
438
type PreferencesRecord struct {
439
-
Type string `json:"$type"`
440
-
ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames,omitempty"`
441
-
CreatedAt string `json:"createdAt"`
0
0
442
}
443
444
func (r *PreferencesRecord) Validate() error {
···
450
return fmt.Errorf("hostname too long: %s", host)
451
}
452
}
0
0
0
0
0
0
453
return nil
454
}
455
456
-
func NewPreferencesRecord(skippedHostnames []string) *PreferencesRecord {
457
-
return &PreferencesRecord{
458
Type: CollectionPreferences,
459
ExternalLinkSkippedHostnames: skippedHostnames,
460
CreatedAt: time.Now().UTC().Format(time.RFC3339),
461
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
462
}
463
464
type APIKeyRecord struct {
···
25
SelectorTypePosition = "TextPositionSelector"
26
)
27
28
+
type SelfLabel struct {
29
+
Val string `json:"val"`
30
+
}
31
+
32
+
type SelfLabels struct {
33
+
Type string `json:"$type"`
34
+
Values []SelfLabel `json:"values"`
35
+
}
36
+
37
+
func NewSelfLabels(vals []string) *SelfLabels {
38
+
if len(vals) == 0 {
39
+
return nil
40
+
}
41
+
labels := make([]SelfLabel, len(vals))
42
+
for i, v := range vals {
43
+
labels[i] = SelfLabel{Val: v}
44
+
}
45
+
return &SelfLabels{
46
+
Type: "com.atproto.label.defs#selfLabels",
47
+
Values: labels,
48
+
}
49
+
}
50
+
51
type Selector struct {
52
Type string `json:"type"`
53
}
···
109
Facets []Facet `json:"facets,omitempty"`
110
Generator *Generator `json:"generator,omitempty"`
111
Rights string `json:"rights,omitempty"`
112
+
Labels *SelfLabels `json:"labels,omitempty"`
113
CreatedAt string `json:"createdAt"`
114
}
115
···
227
Tags []string `json:"tags,omitempty"`
228
Generator *Generator `json:"generator,omitempty"`
229
Rights string `json:"rights,omitempty"`
230
+
Labels *SelfLabels `json:"labels,omitempty"`
231
CreatedAt string `json:"createdAt"`
232
}
233
···
340
Tags []string `json:"tags,omitempty"`
341
Generator *Generator `json:"generator,omitempty"`
342
Rights string `json:"rights,omitempty"`
343
+
Labels *SelfLabels `json:"labels,omitempty"`
344
CreatedAt string `json:"createdAt"`
345
}
346
···
461
return nil
462
}
463
464
+
type LabelerSubscription struct {
465
+
DID string `json:"did"`
466
+
}
467
+
468
+
type LabelPreference struct {
469
+
LabelerDID string `json:"labelerDid"`
470
+
Label string `json:"label"`
471
+
Visibility string `json:"visibility"`
472
+
}
473
+
474
type PreferencesRecord struct {
475
+
Type string `json:"$type"`
476
+
ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames,omitempty"`
477
+
SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers,omitempty"`
478
+
LabelPreferences []LabelPreference `json:"labelPreferences,omitempty"`
479
+
CreatedAt string `json:"createdAt"`
480
}
481
482
func (r *PreferencesRecord) Validate() error {
···
488
return fmt.Errorf("hostname too long: %s", host)
489
}
490
}
491
+
if len(r.SubscribedLabelers) > 50 {
492
+
return fmt.Errorf("too many subscribed labelers")
493
+
}
494
+
if len(r.LabelPreferences) > 500 {
495
+
return fmt.Errorf("too many label preferences")
496
+
}
497
return nil
498
}
499
500
+
func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}) *PreferencesRecord {
501
+
record := &PreferencesRecord{
502
Type: CollectionPreferences,
503
ExternalLinkSkippedHostnames: skippedHostnames,
504
CreatedAt: time.Now().UTC().Format(time.RFC3339),
505
}
506
+
507
+
if labelers != nil {
508
+
switch v := labelers.(type) {
509
+
case []LabelerSubscription:
510
+
record.SubscribedLabelers = v
511
+
}
512
+
}
513
+
514
+
if labelPrefs != nil {
515
+
switch v := labelPrefs.(type) {
516
+
case []LabelPreference:
517
+
record.LabelPreferences = v
518
+
}
519
+
}
520
+
521
+
return record
522
}
523
524
type APIKeyRecord struct {
+5
lexicons/at/margin/annotation.json
···
70
"format": "uri",
71
"description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)"
72
},
0
0
0
0
0
73
"createdAt": {
74
"type": "string",
75
"format": "datetime"
···
70
"format": "uri",
71
"description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)"
72
},
73
+
"labels": {
74
+
"type": "ref",
75
+
"ref": "com.atproto.label.defs#selfLabels",
76
+
"description": "Self-applied content labels for this annotation"
77
+
},
78
"createdAt": {
79
"type": "string",
80
"format": "datetime"
+5
lexicons/at/margin/bookmark.json
···
63
"format": "uri",
64
"description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)"
65
},
0
0
0
0
0
66
"createdAt": {
67
"type": "string",
68
"format": "datetime"
···
63
"format": "uri",
64
"description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)"
65
},
66
+
"labels": {
67
+
"type": "ref",
68
+
"ref": "com.atproto.label.defs#selfLabels",
69
+
"description": "Self-applied content labels for this bookmark"
70
+
},
71
"createdAt": {
72
"type": "string",
73
"format": "datetime"
+5
lexicons/at/margin/highlight.json
···
53
"format": "uri",
54
"description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)"
55
},
0
0
0
0
0
56
"createdAt": {
57
"type": "string",
58
"format": "datetime"
···
53
"format": "uri",
54
"description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)"
55
},
56
+
"labels": {
57
+
"type": "ref",
58
+
"ref": "com.atproto.label.defs#selfLabels",
59
+
"description": "Self-applied content labels for this highlight"
60
+
},
61
"createdAt": {
62
"type": "string",
63
"format": "datetime"
+39
lexicons/at/margin/preferences.json
···
19
},
20
"maxLength": 100
21
},
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
22
"createdAt": {
23
"type": "string",
24
"format": "datetime"
···
19
},
20
"maxLength": 100
21
},
22
+
"subscribedLabelers": {
23
+
"type": "array",
24
+
"description": "List of labeler services the user subscribes to for content moderation.",
25
+
"items": {
26
+
"type": "object",
27
+
"required": ["did"],
28
+
"properties": {
29
+
"did": {
30
+
"type": "string",
31
+
"description": "DID of the labeler service."
32
+
}
33
+
}
34
+
},
35
+
"maxLength": 50
36
+
},
37
+
"labelPreferences": {
38
+
"type": "array",
39
+
"description": "Per-label visibility preferences for subscribed labelers.",
40
+
"items": {
41
+
"type": "object",
42
+
"required": ["labelerDid", "label", "visibility"],
43
+
"properties": {
44
+
"labelerDid": {
45
+
"type": "string",
46
+
"description": "DID of the labeler service."
47
+
},
48
+
"label": {
49
+
"type": "string",
50
+
"description": "The label identifier (e.g. sexual, violence, spam)."
51
+
},
52
+
"visibility": {
53
+
"type": "string",
54
+
"description": "How to handle content with this label: hide, warn, or ignore.",
55
+
"knownValues": ["hide", "warn", "ignore"]
56
+
}
57
+
}
58
+
},
59
+
"maxLength": 500
60
+
},
61
"createdAt": {
62
"type": "string",
63
"format": "datetime"
+10
web/src/App.tsx
···
20
UserUrlWrapper,
21
} from "./routes/wrappers";
22
import About from "./views/About";
0
23
24
export default function App() {
25
React.useEffect(() => {
···
174
element={
175
<AppLayout>
176
<UserUrlWrapper />
0
0
0
0
0
0
0
0
0
177
</AppLayout>
178
}
179
/>
···
20
UserUrlWrapper,
21
} from "./routes/wrappers";
22
import About from "./views/About";
23
+
import AdminModeration from "./views/core/AdminModeration";
24
25
export default function App() {
26
React.useEffect(() => {
···
175
element={
176
<AppLayout>
177
<UserUrlWrapper />
178
+
</AppLayout>
179
+
}
180
+
/>
181
+
182
+
<Route
183
+
path="/admin/moderation"
184
+
element={
185
+
<AppLayout>
186
+
<AdminModeration />
187
</AppLayout>
188
}
189
/>
+257
-7
web/src/api/client.ts
···
263
title?: string;
264
selector?: { exact: string; prefix?: string; suffix?: string };
265
tags?: string[];
0
266
}
267
268
export async function createAnnotation({
···
271
title,
272
selector,
273
tags,
0
274
}: CreateAnnotationParams) {
275
try {
276
const res = await apiRequest("/api/annotations", {
277
method: "POST",
278
-
body: JSON.stringify({ url, text, title, selector, tags }),
279
});
280
if (!res.ok) throw new Error(await res.text());
281
const raw = await res.json();
···
292
color?: string;
293
tags?: string[];
294
title?: string;
0
295
}
296
297
export async function createHighlight({
···
300
color,
301
tags,
302
title,
0
303
}: CreateHighlightParams) {
304
try {
305
const res = await apiRequest("/api/highlights", {
306
method: "POST",
307
-
body: JSON.stringify({ url, selector, color, tags, title }),
308
});
309
if (!res.ok) throw new Error(await res.text());
310
const raw = await res.json();
···
425
uri: string,
426
text: string,
427
tags?: string[],
0
428
): Promise<boolean> {
429
try {
430
const res = await apiRequest(
431
`/api/annotations?uri=${encodeURIComponent(uri)}`,
432
{
433
method: "PUT",
434
-
body: JSON.stringify({ text, tags }),
435
},
436
);
437
return res.ok;
···
445
uri: string,
446
color: string,
447
tags?: string[],
0
448
): Promise<boolean> {
449
try {
450
const res = await apiRequest(
451
`/api/highlights?uri=${encodeURIComponent(uri)}`,
452
{
453
method: "PUT",
454
-
body: JSON.stringify({ color, tags }),
455
},
456
);
457
return res.ok;
···
466
title?: string,
467
description?: string,
468
tags?: string[],
0
469
): Promise<boolean> {
470
try {
471
const res = await apiRequest(
472
`/api/bookmarks?uri=${encodeURIComponent(uri)}`,
473
{
474
method: "PUT",
475
-
body: JSON.stringify({ title, description, tags }),
476
},
477
);
478
return res.ok;
···
942
return { annotations: [], highlights: [] };
943
}
944
}
945
-
export async function getPreferences(): Promise<{
0
0
0
0
0
0
946
externalLinkSkippedHostnames?: string[];
947
-
}> {
0
0
0
0
948
try {
949
const res = await apiRequest("/api/preferences", {
950
skipAuthRedirect: true,
···
959
960
export async function updatePreferences(prefs: {
961
externalLinkSkippedHostnames?: string[];
0
0
962
}): Promise<boolean> {
963
try {
964
const res = await apiRequest("/api/preferences", {
···
971
return false;
972
}
973
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
263
title?: string;
264
selector?: { exact: string; prefix?: string; suffix?: string };
265
tags?: string[];
266
+
labels?: string[];
267
}
268
269
export async function createAnnotation({
···
272
title,
273
selector,
274
tags,
275
+
labels,
276
}: CreateAnnotationParams) {
277
try {
278
const res = await apiRequest("/api/annotations", {
279
method: "POST",
280
+
body: JSON.stringify({ url, text, title, selector, tags, labels }),
281
});
282
if (!res.ok) throw new Error(await res.text());
283
const raw = await res.json();
···
294
color?: string;
295
tags?: string[];
296
title?: string;
297
+
labels?: string[];
298
}
299
300
export async function createHighlight({
···
303
color,
304
tags,
305
title,
306
+
labels,
307
}: CreateHighlightParams) {
308
try {
309
const res = await apiRequest("/api/highlights", {
310
method: "POST",
311
+
body: JSON.stringify({ url, selector, color, tags, title, labels }),
312
});
313
if (!res.ok) throw new Error(await res.text());
314
const raw = await res.json();
···
429
uri: string,
430
text: string,
431
tags?: string[],
432
+
labels?: string[],
433
): Promise<boolean> {
434
try {
435
const res = await apiRequest(
436
`/api/annotations?uri=${encodeURIComponent(uri)}`,
437
{
438
method: "PUT",
439
+
body: JSON.stringify({ text, tags, labels }),
440
},
441
);
442
return res.ok;
···
450
uri: string,
451
color: string,
452
tags?: string[],
453
+
labels?: string[],
454
): Promise<boolean> {
455
try {
456
const res = await apiRequest(
457
`/api/highlights?uri=${encodeURIComponent(uri)}`,
458
{
459
method: "PUT",
460
+
body: JSON.stringify({ color, tags, labels }),
461
},
462
);
463
return res.ok;
···
472
title?: string,
473
description?: string,
474
tags?: string[],
475
+
labels?: string[],
476
): Promise<boolean> {
477
try {
478
const res = await apiRequest(
479
`/api/bookmarks?uri=${encodeURIComponent(uri)}`,
480
{
481
method: "PUT",
482
+
body: JSON.stringify({ title, description, tags, labels }),
483
},
484
);
485
return res.ok;
···
949
return { annotations: [], highlights: [] };
950
}
951
}
952
+
import type {
953
+
LabelerSubscription,
954
+
LabelPreference,
955
+
LabelerInfo,
956
+
} from "../types";
957
+
958
+
export interface PreferencesResponse {
959
externalLinkSkippedHostnames?: string[];
960
+
subscribedLabelers?: LabelerSubscription[];
961
+
labelPreferences?: LabelPreference[];
962
+
}
963
+
964
+
export async function getPreferences(): Promise<PreferencesResponse> {
965
try {
966
const res = await apiRequest("/api/preferences", {
967
skipAuthRedirect: true,
···
976
977
export async function updatePreferences(prefs: {
978
externalLinkSkippedHostnames?: string[];
979
+
subscribedLabelers?: LabelerSubscription[];
980
+
labelPreferences?: LabelPreference[];
981
}): Promise<boolean> {
982
try {
983
const res = await apiRequest("/api/preferences", {
···
990
return false;
991
}
992
}
993
+
994
+
export async function getLabelerInfo(): Promise<LabelerInfo | null> {
995
+
try {
996
+
const res = await apiRequest("/moderation/labeler", {
997
+
skipAuthRedirect: true,
998
+
});
999
+
if (!res.ok) return null;
1000
+
return await res.json();
1001
+
} catch (e) {
1002
+
console.error("Failed to fetch labeler info:", e);
1003
+
return null;
1004
+
}
1005
+
}
1006
+
1007
+
import type {
1008
+
ModerationRelationship,
1009
+
BlockedUser,
1010
+
MutedUser,
1011
+
ModerationReport,
1012
+
ReportReasonType,
1013
+
} from "../types";
1014
+
1015
+
export async function blockUser(did: string): Promise<boolean> {
1016
+
try {
1017
+
const res = await apiRequest("/api/moderation/block", {
1018
+
method: "POST",
1019
+
body: JSON.stringify({ did }),
1020
+
});
1021
+
return res.ok;
1022
+
} catch (e) {
1023
+
console.error("Failed to block user:", e);
1024
+
return false;
1025
+
}
1026
+
}
1027
+
1028
+
export async function unblockUser(did: string): Promise<boolean> {
1029
+
try {
1030
+
const res = await apiRequest(
1031
+
`/api/moderation/block?did=${encodeURIComponent(did)}`,
1032
+
{ method: "DELETE" },
1033
+
);
1034
+
return res.ok;
1035
+
} catch (e) {
1036
+
console.error("Failed to unblock user:", e);
1037
+
return false;
1038
+
}
1039
+
}
1040
+
1041
+
export async function getBlocks(): Promise<BlockedUser[]> {
1042
+
try {
1043
+
const res = await apiRequest("/api/moderation/blocks");
1044
+
if (!res.ok) return [];
1045
+
const data = await res.json();
1046
+
return data.items || [];
1047
+
} catch (e) {
1048
+
console.error("Failed to fetch blocks:", e);
1049
+
return [];
1050
+
}
1051
+
}
1052
+
1053
+
export async function muteUser(did: string): Promise<boolean> {
1054
+
try {
1055
+
const res = await apiRequest("/api/moderation/mute", {
1056
+
method: "POST",
1057
+
body: JSON.stringify({ did }),
1058
+
});
1059
+
return res.ok;
1060
+
} catch (e) {
1061
+
console.error("Failed to mute user:", e);
1062
+
return false;
1063
+
}
1064
+
}
1065
+
1066
+
export async function unmuteUser(did: string): Promise<boolean> {
1067
+
try {
1068
+
const res = await apiRequest(
1069
+
`/api/moderation/mute?did=${encodeURIComponent(did)}`,
1070
+
{ method: "DELETE" },
1071
+
);
1072
+
return res.ok;
1073
+
} catch (e) {
1074
+
console.error("Failed to unmute user:", e);
1075
+
return false;
1076
+
}
1077
+
}
1078
+
1079
+
export async function getMutes(): Promise<MutedUser[]> {
1080
+
try {
1081
+
const res = await apiRequest("/api/moderation/mutes");
1082
+
if (!res.ok) return [];
1083
+
const data = await res.json();
1084
+
return data.items || [];
1085
+
} catch (e) {
1086
+
console.error("Failed to fetch mutes:", e);
1087
+
return [];
1088
+
}
1089
+
}
1090
+
1091
+
export async function getModerationRelationship(
1092
+
did: string,
1093
+
): Promise<ModerationRelationship> {
1094
+
try {
1095
+
const res = await apiRequest(
1096
+
`/api/moderation/relationship?did=${encodeURIComponent(did)}`,
1097
+
{ skipAuthRedirect: true },
1098
+
);
1099
+
if (!res.ok) return { blocking: false, muting: false, blockedBy: false };
1100
+
return await res.json();
1101
+
} catch (e) {
1102
+
console.error("Failed to get moderation relationship:", e);
1103
+
return { blocking: false, muting: false, blockedBy: false };
1104
+
}
1105
+
}
1106
+
1107
+
export async function reportUser(params: {
1108
+
subjectDid: string;
1109
+
subjectUri?: string;
1110
+
reasonType: ReportReasonType;
1111
+
reasonText?: string;
1112
+
}): Promise<boolean> {
1113
+
try {
1114
+
const res = await apiRequest("/api/moderation/report", {
1115
+
method: "POST",
1116
+
body: JSON.stringify(params),
1117
+
});
1118
+
return res.ok;
1119
+
} catch (e) {
1120
+
console.error("Failed to submit report:", e);
1121
+
return false;
1122
+
}
1123
+
}
1124
+
1125
+
export async function checkAdminAccess(): Promise<boolean> {
1126
+
try {
1127
+
const res = await apiRequest("/api/moderation/admin/check", {
1128
+
skipAuthRedirect: true,
1129
+
});
1130
+
if (!res.ok) return false;
1131
+
const data = await res.json();
1132
+
return data.isAdmin || false;
1133
+
} catch (e) {
1134
+
return false;
1135
+
}
1136
+
}
1137
+
1138
+
export async function getAdminReports(
1139
+
status?: string,
1140
+
limit = 50,
1141
+
offset = 0,
1142
+
): Promise<{
1143
+
items: ModerationReport[];
1144
+
totalItems: number;
1145
+
pendingCount: number;
1146
+
}> {
1147
+
try {
1148
+
const params = new URLSearchParams();
1149
+
if (status) params.append("status", status);
1150
+
params.append("limit", limit.toString());
1151
+
params.append("offset", offset.toString());
1152
+
const res = await apiRequest(
1153
+
`/api/moderation/admin/reports?${params.toString()}`,
1154
+
);
1155
+
if (!res.ok) return { items: [], totalItems: 0, pendingCount: 0 };
1156
+
return await res.json();
1157
+
} catch (e) {
1158
+
console.error("Failed to fetch admin reports:", e);
1159
+
return { items: [], totalItems: 0, pendingCount: 0 };
1160
+
}
1161
+
}
1162
+
1163
+
export async function adminTakeAction(params: {
1164
+
reportId: number;
1165
+
action: string;
1166
+
comment?: string;
1167
+
}): Promise<boolean> {
1168
+
try {
1169
+
const res = await apiRequest("/api/moderation/admin/action", {
1170
+
method: "POST",
1171
+
body: JSON.stringify(params),
1172
+
});
1173
+
return res.ok;
1174
+
} catch (e) {
1175
+
console.error("Failed to take moderation action:", e);
1176
+
return false;
1177
+
}
1178
+
}
1179
+
1180
+
export async function adminCreateLabel(params: {
1181
+
src: string;
1182
+
uri?: string;
1183
+
val: string;
1184
+
}): Promise<boolean> {
1185
+
try {
1186
+
const res = await apiRequest("/api/moderation/admin/label", {
1187
+
method: "POST",
1188
+
body: JSON.stringify(params),
1189
+
});
1190
+
return res.ok;
1191
+
} catch (e) {
1192
+
console.error("Failed to create label:", e);
1193
+
return false;
1194
+
}
1195
+
}
1196
+
1197
+
export async function adminDeleteLabel(id: number): Promise<boolean> {
1198
+
try {
1199
+
const res = await apiRequest(`/api/moderation/admin/label?id=${id}`, {
1200
+
method: "DELETE",
1201
+
});
1202
+
return res.ok;
1203
+
} catch (e) {
1204
+
console.error("Failed to delete label:", e);
1205
+
return false;
1206
+
}
1207
+
}
1208
+
1209
+
export async function adminGetLabels(
1210
+
limit = 50,
1211
+
offset = 0,
1212
+
): Promise<{ items: any[] }> {
1213
+
try {
1214
+
const res = await apiRequest(
1215
+
`/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
1216
+
);
1217
+
if (!res.ok) return { items: [] };
1218
+
return await res.json();
1219
+
} catch (e) {
1220
+
console.error("Failed to fetch labels:", e);
1221
+
return { items: [] };
1222
+
}
1223
+
}
+198
-11
web/src/components/common/Card.tsx
···
1
import React, { useState } from "react";
2
import { formatDistanceToNow } from "date-fns";
3
import RichText from "./RichText";
0
0
4
import {
5
MessageSquare,
6
Heart,
···
9
Trash2,
10
Edit3,
11
Globe,
0
0
0
0
0
12
} from "lucide-react";
13
import ShareMenu from "../modals/ShareMenu";
14
import AddToCollectionModal from "../modals/AddToCollectionModal";
15
import ExternalLinkModal from "../modals/ExternalLinkModal";
0
0
16
import { clsx } from "clsx";
17
-
import { likeItem, unlikeItem, deleteItem } from "../../api/client";
0
0
0
0
0
0
18
import { $user } from "../../store/auth";
19
import { $preferences } from "../../store/preferences";
20
import { useStore } from "@nanostores/react";
21
-
import type { AnnotationItem } from "../../types";
0
0
0
0
22
import { Link } from "react-router-dom";
23
import { Avatar } from "../ui";
24
import CollectionIcon from "./CollectionIcon";
25
import ProfileHoverCard from "./ProfileHoverCard";
26
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
27
interface CardProps {
28
item: AnnotationItem;
29
onDelete?: (uri: string) => void;
0
30
hideShare?: boolean;
31
}
32
33
-
export default function Card({ item, onDelete, hideShare }: CardProps) {
0
0
0
0
0
0
34
const user = useStore($user);
0
35
const isAuthor = user && item.author?.did === user.did;
36
37
const [liked, setLiked] = useState(!!item.viewer?.like);
···
39
const [showCollectionModal, setShowCollectionModal] = useState(false);
40
const [showExternalLinkModal, setShowExternalLinkModal] = useState(false);
41
const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null);
0
0
0
0
0
0
0
0
0
0
0
42
43
React.useEffect(() => {
44
setLiked(!!item.viewer?.like);
···
183
const displayImage = ogData?.image;
184
185
return (
186
-
<article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all">
187
{item.collection && (
188
<div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2">
189
{item.addedBy && item.addedBy.did !== item.author?.did ? (
···
221
<div className="flex items-start gap-3">
222
<ProfileHoverCard did={item.author?.did}>
223
<Link to={`/profile/${item.author?.did}`} className="shrink-0">
224
-
<Avatar
225
-
did={item.author?.did}
226
-
avatar={item.author?.avatar}
227
-
size="md"
228
-
/>
0
0
0
0
0
0
0
0
0
0
0
229
</Link>
230
</ProfileHoverCard>
231
···
282
})()}
283
</div>
284
285
-
{pageUrl && !isBookmark && (
286
<a
287
href={pageUrl}
288
target="_blank"
···
297
</div>
298
</div>
299
300
-
<div className="mt-3 ml-[52px]">
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
301
{isBookmark && (
302
<a
303
href={pageUrl || "#"}
···
450
<>
451
<div className="flex-1" />
452
<button
0
453
className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-all"
454
title="Edit"
455
>
···
464
</button>
465
</>
466
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
467
</div>
468
469
<AddToCollectionModal
···
476
isOpen={showExternalLinkModal}
477
onClose={() => setShowExternalLinkModal(false)}
478
url={externalLinkUrl}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
479
/>
480
</article>
481
);
···
1
import React, { useState } from "react";
2
import { formatDistanceToNow } from "date-fns";
3
import RichText from "./RichText";
4
+
import MoreMenu from "./MoreMenu";
5
+
import type { MoreMenuItem } from "./MoreMenu";
6
import {
7
MessageSquare,
8
Heart,
···
11
Trash2,
12
Edit3,
13
Globe,
14
+
ShieldBan,
15
+
VolumeX,
16
+
Flag,
17
+
EyeOff,
18
+
Eye,
19
} from "lucide-react";
20
import ShareMenu from "../modals/ShareMenu";
21
import AddToCollectionModal from "../modals/AddToCollectionModal";
22
import ExternalLinkModal from "../modals/ExternalLinkModal";
23
+
import ReportModal from "../modals/ReportModal";
24
+
import EditItemModal from "../modals/EditItemModal";
25
import { clsx } from "clsx";
26
+
import {
27
+
likeItem,
28
+
unlikeItem,
29
+
deleteItem,
30
+
blockUser,
31
+
muteUser,
32
+
} from "../../api/client";
33
import { $user } from "../../store/auth";
34
import { $preferences } from "../../store/preferences";
35
import { useStore } from "@nanostores/react";
36
+
import type {
37
+
AnnotationItem,
38
+
ContentLabel,
39
+
LabelVisibility,
40
+
} from "../../types";
41
import { Link } from "react-router-dom";
42
import { Avatar } from "../ui";
43
import CollectionIcon from "./CollectionIcon";
44
import ProfileHoverCard from "./ProfileHoverCard";
45
46
+
const LABEL_DESCRIPTIONS: Record<string, string> = {
47
+
sexual: "Sexual Content",
48
+
nudity: "Nudity",
49
+
violence: "Violence",
50
+
gore: "Graphic Content",
51
+
spam: "Spam",
52
+
misleading: "Misleading",
53
+
};
54
+
55
+
function getContentWarning(
56
+
labels?: ContentLabel[],
57
+
prefs?: {
58
+
labelPreferences: {
59
+
labelerDid: string;
60
+
label: string;
61
+
visibility: LabelVisibility;
62
+
}[];
63
+
},
64
+
): {
65
+
label: string;
66
+
description: string;
67
+
visibility: LabelVisibility;
68
+
isAccountWide: boolean;
69
+
} | null {
70
+
if (!labels || labels.length === 0) return null;
71
+
const priority = [
72
+
"gore",
73
+
"violence",
74
+
"nudity",
75
+
"sexual",
76
+
"misleading",
77
+
"spam",
78
+
];
79
+
for (const p of priority) {
80
+
const match = labels.find((l) => l.val === p);
81
+
if (match) {
82
+
const pref = prefs?.labelPreferences.find(
83
+
(lp) => lp.label === p && lp.labelerDid === match.src,
84
+
);
85
+
const visibility: LabelVisibility = pref?.visibility || "warn";
86
+
if (visibility === "ignore") continue;
87
+
return {
88
+
label: p,
89
+
description: LABEL_DESCRIPTIONS[p] || p,
90
+
visibility,
91
+
isAccountWide: match.scope === "account",
92
+
};
93
+
}
94
+
}
95
+
return null;
96
+
}
97
+
98
interface CardProps {
99
item: AnnotationItem;
100
onDelete?: (uri: string) => void;
101
+
onUpdate?: (item: AnnotationItem) => void;
102
hideShare?: boolean;
103
}
104
105
+
export default function Card({
106
+
item: initialItem,
107
+
onDelete,
108
+
onUpdate,
109
+
hideShare,
110
+
}: CardProps) {
111
+
const [item, setItem] = useState(initialItem);
112
const user = useStore($user);
113
+
const preferences = useStore($preferences);
114
const isAuthor = user && item.author?.did === user.did;
115
116
const [liked, setLiked] = useState(!!item.viewer?.like);
···
118
const [showCollectionModal, setShowCollectionModal] = useState(false);
119
const [showExternalLinkModal, setShowExternalLinkModal] = useState(false);
120
const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null);
121
+
const [showReportModal, setShowReportModal] = useState(false);
122
+
const [showEditModal, setShowEditModal] = useState(false);
123
+
const [contentRevealed, setContentRevealed] = useState(false);
124
+
125
+
const contentWarning = getContentWarning(item.labels, preferences);
126
+
127
+
if (contentWarning?.visibility === "hide") return null;
128
+
129
+
React.useEffect(() => {
130
+
setItem(initialItem);
131
+
}, [initialItem]);
132
133
React.useEffect(() => {
134
setLiked(!!item.viewer?.like);
···
273
const displayImage = ogData?.image;
274
275
return (
276
+
<article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative">
277
{item.collection && (
278
<div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2">
279
{item.addedBy && item.addedBy.did !== item.author?.did ? (
···
311
<div className="flex items-start gap-3">
312
<ProfileHoverCard did={item.author?.did}>
313
<Link to={`/profile/${item.author?.did}`} className="shrink-0">
314
+
<div className="rounded-full overflow-hidden">
315
+
<div
316
+
className={clsx(
317
+
"transition-all",
318
+
contentWarning?.isAccountWide &&
319
+
!contentRevealed &&
320
+
"blur-md",
321
+
)}
322
+
>
323
+
<Avatar
324
+
did={item.author?.did}
325
+
avatar={item.author?.avatar}
326
+
size="md"
327
+
/>
328
+
</div>
329
+
</div>
330
</Link>
331
</ProfileHoverCard>
332
···
383
})()}
384
</div>
385
386
+
{pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && (
387
<a
388
href={pageUrl}
389
target="_blank"
···
398
</div>
399
</div>
400
401
+
<div className="mt-3 ml-[52px] relative">
402
+
{contentWarning && !contentRevealed && (
403
+
<div className="absolute inset-0 z-10 rounded-lg bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center gap-2 py-4">
404
+
<div className="flex items-center gap-2 text-surface-500 dark:text-surface-400">
405
+
<EyeOff size={16} />
406
+
<span className="text-sm font-medium">
407
+
{contentWarning.description}
408
+
</span>
409
+
</div>
410
+
<button
411
+
onClick={() => setContentRevealed(true)}
412
+
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600 transition-colors"
413
+
>
414
+
<Eye size={12} />
415
+
Show
416
+
</button>
417
+
</div>
418
+
)}
419
+
{contentWarning && contentRevealed && (
420
+
<button
421
+
onClick={() => setContentRevealed(false)}
422
+
className="flex items-center gap-1.5 mb-2 px-2.5 py-1 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
423
+
>
424
+
<EyeOff size={12} />
425
+
Hide Content
426
+
</button>
427
+
)}
428
{isBookmark && (
429
<a
430
href={pageUrl || "#"}
···
577
<>
578
<div className="flex-1" />
579
<button
580
+
onClick={() => setShowEditModal(true)}
581
className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-all"
582
title="Edit"
583
>
···
592
</button>
593
</>
594
)}
595
+
596
+
{!isAuthor && user && (
597
+
<>
598
+
<div className="flex-1" />
599
+
<MoreMenu
600
+
items={(() => {
601
+
const menuItems: MoreMenuItem[] = [
602
+
{
603
+
label: "Report",
604
+
icon: <Flag size={14} />,
605
+
onClick: () => setShowReportModal(true),
606
+
variant: "danger",
607
+
},
608
+
{
609
+
label: `Mute @${item.author?.handle || "user"}`,
610
+
icon: <VolumeX size={14} />,
611
+
onClick: async () => {
612
+
if (item.author?.did) {
613
+
await muteUser(item.author.did);
614
+
onDelete?.(item.uri);
615
+
}
616
+
},
617
+
},
618
+
{
619
+
label: `Block @${item.author?.handle || "user"}`,
620
+
icon: <ShieldBan size={14} />,
621
+
onClick: async () => {
622
+
if (item.author?.did) {
623
+
await blockUser(item.author.did);
624
+
onDelete?.(item.uri);
625
+
}
626
+
},
627
+
variant: "danger",
628
+
},
629
+
];
630
+
return menuItems;
631
+
})()}
632
+
/>
633
+
</>
634
+
)}
635
</div>
636
637
<AddToCollectionModal
···
644
isOpen={showExternalLinkModal}
645
onClose={() => setShowExternalLinkModal(false)}
646
url={externalLinkUrl}
647
+
/>
648
+
649
+
<ReportModal
650
+
isOpen={showReportModal}
651
+
onClose={() => setShowReportModal(false)}
652
+
subjectDid={item.author?.did || ""}
653
+
subjectUri={item.uri}
654
+
subjectHandle={item.author?.handle}
655
+
/>
656
+
657
+
<EditItemModal
658
+
isOpen={showEditModal}
659
+
onClose={() => setShowEditModal(false)}
660
+
item={item}
661
+
type={type}
662
+
onSaved={(updated) => {
663
+
setItem(updated);
664
+
onUpdate?.(updated);
665
+
}}
666
/>
667
</article>
668
);
+99
web/src/components/common/MoreMenu.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import React, { useState, useRef, useEffect } from "react";
2
+
import { MoreHorizontal } from "lucide-react";
3
+
import { clsx } from "clsx";
4
+
5
+
export interface MoreMenuItem {
6
+
label: string;
7
+
icon?: React.ReactNode;
8
+
onClick: () => void;
9
+
variant?: "default" | "danger";
10
+
disabled?: boolean;
11
+
}
12
+
13
+
interface MoreMenuProps {
14
+
items: MoreMenuItem[];
15
+
className?: string;
16
+
}
17
+
18
+
export default function MoreMenu({ items, className }: MoreMenuProps) {
19
+
const [isOpen, setIsOpen] = useState(false);
20
+
const buttonRef = useRef<HTMLButtonElement>(null);
21
+
const menuRef = useRef<HTMLDivElement>(null);
22
+
23
+
useEffect(() => {
24
+
if (!isOpen) return;
25
+
26
+
const handleClickOutside = (e: MouseEvent) => {
27
+
if (
28
+
menuRef.current &&
29
+
!menuRef.current.contains(e.target as Node) &&
30
+
buttonRef.current &&
31
+
!buttonRef.current.contains(e.target as Node)
32
+
) {
33
+
setIsOpen(false);
34
+
}
35
+
};
36
+
37
+
const handleScroll = () => setIsOpen(false);
38
+
const handleEscape = (e: KeyboardEvent) => {
39
+
if (e.key === "Escape") setIsOpen(false);
40
+
};
41
+
42
+
document.addEventListener("mousedown", handleClickOutside);
43
+
document.addEventListener("scroll", handleScroll, true);
44
+
document.addEventListener("keydown", handleEscape);
45
+
46
+
return () => {
47
+
document.removeEventListener("mousedown", handleClickOutside);
48
+
document.removeEventListener("scroll", handleScroll, true);
49
+
document.removeEventListener("keydown", handleEscape);
50
+
};
51
+
}, [isOpen]);
52
+
53
+
if (items.length === 0) return null;
54
+
55
+
return (
56
+
<div className={clsx("relative", className)}>
57
+
<button
58
+
ref={buttonRef}
59
+
onClick={() => setIsOpen(!isOpen)}
60
+
className="flex items-center px-2 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all"
61
+
title="More options"
62
+
>
63
+
<MoreHorizontal size={16} />
64
+
</button>
65
+
66
+
{isOpen && (
67
+
<div
68
+
ref={menuRef}
69
+
className="absolute right-0 top-full mt-1 z-50 min-w-[180px] bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-lg py-1 animate-fade-in"
70
+
>
71
+
{items.map((item, i) => (
72
+
<button
73
+
key={i}
74
+
onClick={() => {
75
+
item.onClick();
76
+
setIsOpen(false);
77
+
}}
78
+
disabled={item.disabled}
79
+
className={clsx(
80
+
"w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors text-left",
81
+
item.variant === "danger"
82
+
? "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
83
+
: "text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800",
84
+
item.disabled && "opacity-50 cursor-not-allowed",
85
+
)}
86
+
>
87
+
{item.icon && (
88
+
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
89
+
{item.icon}
90
+
</span>
91
+
)}
92
+
{item.label}
93
+
</button>
94
+
))}
95
+
</div>
96
+
)}
97
+
</div>
98
+
);
99
+
}
+54
-2
web/src/components/feed/Composer.tsx
···
1
import React, { useState } from "react";
2
import { createAnnotation, createHighlight } from "../../api/client";
3
-
import type { Selector } from "../../types";
4
-
import { X } from "lucide-react";
0
0
0
0
0
0
0
0
0
5
6
interface ComposerProps {
7
url: string;
···
23
const [loading, setLoading] = useState(false);
24
const [error, setError] = useState<string | null>(null);
25
const [showQuoteInput, setShowQuoteInput] = useState(false);
0
0
26
27
const highlightedText =
28
selector?.type === "TextQuoteSelector" ? selector.exact : null;
···
59
},
60
color: "yellow",
61
tags: tagList,
0
62
});
63
} else {
64
await createAnnotation({
···
66
text: text.trim(),
67
selector: finalSelector || undefined,
68
tags: tagList,
0
69
});
70
}
71
···
171
className="w-full p-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none text-sm"
172
disabled={loading}
173
/>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
174
175
<div className="flex items-center justify-between pt-2">
176
<span
···
1
import React, { useState } from "react";
2
import { createAnnotation, createHighlight } from "../../api/client";
3
+
import type { Selector, ContentLabelValue } from "../../types";
4
+
import { X, ShieldAlert } from "lucide-react";
5
+
6
+
const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [
7
+
{ value: "sexual", label: "Sexual" },
8
+
{ value: "nudity", label: "Nudity" },
9
+
{ value: "violence", label: "Violence" },
10
+
{ value: "gore", label: "Gore" },
11
+
{ value: "spam", label: "Spam" },
12
+
{ value: "misleading", label: "Misleading" },
13
+
];
14
15
interface ComposerProps {
16
url: string;
···
32
const [loading, setLoading] = useState(false);
33
const [error, setError] = useState<string | null>(null);
34
const [showQuoteInput, setShowQuoteInput] = useState(false);
35
+
const [selfLabels, setSelfLabels] = useState<ContentLabelValue[]>([]);
36
+
const [showLabelPicker, setShowLabelPicker] = useState(false);
37
38
const highlightedText =
39
selector?.type === "TextQuoteSelector" ? selector.exact : null;
···
70
},
71
color: "yellow",
72
tags: tagList,
73
+
labels: selfLabels.length > 0 ? selfLabels : undefined,
74
});
75
} else {
76
await createAnnotation({
···
78
text: text.trim(),
79
selector: finalSelector || undefined,
80
tags: tagList,
81
+
labels: selfLabels.length > 0 ? selfLabels : undefined,
82
});
83
}
84
···
184
className="w-full p-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none text-sm"
185
disabled={loading}
186
/>
187
+
188
+
<div>
189
+
<button
190
+
type="button"
191
+
onClick={() => setShowLabelPicker(!showLabelPicker)}
192
+
className="flex items-center gap-1.5 text-sm text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors"
193
+
>
194
+
<ShieldAlert size={14} />
195
+
<span>
196
+
Content Warning
197
+
{selfLabels.length > 0 ? ` (${selfLabels.length})` : ""}
198
+
</span>
199
+
</button>
200
+
201
+
{showLabelPicker && (
202
+
<div className="mt-2 flex flex-wrap gap-1.5">
203
+
{SELF_LABEL_OPTIONS.map((opt) => (
204
+
<button
205
+
key={opt.value}
206
+
type="button"
207
+
onClick={() =>
208
+
setSelfLabels((prev) =>
209
+
prev.includes(opt.value)
210
+
? prev.filter((v) => v !== opt.value)
211
+
: [...prev, opt.value],
212
+
)
213
+
}
214
+
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${
215
+
selfLabels.includes(opt.value)
216
+
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 ring-1 ring-amber-300 dark:ring-amber-700"
217
+
: "bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700"
218
+
}`}
219
+
>
220
+
{opt.label}
221
+
</button>
222
+
))}
223
+
</div>
224
+
)}
225
+
</div>
226
227
<div className="flex items-center justify-between pt-2">
228
<span
+367
web/src/components/modals/EditItemModal.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import React, { useState, useEffect } from "react";
2
+
import { X, ShieldAlert } from "lucide-react";
3
+
import {
4
+
updateAnnotation,
5
+
updateHighlight,
6
+
updateBookmark,
7
+
} from "../../api/client";
8
+
import type { AnnotationItem, ContentLabelValue } from "../../types";
9
+
10
+
const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [
11
+
{ value: "sexual", label: "Sexual" },
12
+
{ value: "nudity", label: "Nudity" },
13
+
{ value: "violence", label: "Violence" },
14
+
{ value: "gore", label: "Gore" },
15
+
{ value: "spam", label: "Spam" },
16
+
{ value: "misleading", label: "Misleading" },
17
+
];
18
+
19
+
const HIGHLIGHT_COLORS = [
20
+
{ value: "yellow", bg: "bg-yellow-400", ring: "ring-yellow-500" },
21
+
{ value: "green", bg: "bg-green-400", ring: "ring-green-500" },
22
+
{ value: "blue", bg: "bg-blue-400", ring: "ring-blue-500" },
23
+
{ value: "red", bg: "bg-red-400", ring: "ring-red-500" },
24
+
];
25
+
26
+
interface EditItemModalProps {
27
+
isOpen: boolean;
28
+
onClose: () => void;
29
+
item: AnnotationItem;
30
+
type: "annotation" | "highlight" | "bookmark";
31
+
onSaved?: (item: AnnotationItem) => void;
32
+
}
33
+
34
+
export default function EditItemModal({
35
+
isOpen,
36
+
onClose,
37
+
item,
38
+
type,
39
+
onSaved,
40
+
}: EditItemModalProps) {
41
+
const [text, setText] = useState(item.body?.value || "");
42
+
const [tags, setTags] = useState<string[]>(item.tags || []);
43
+
const [tagInput, setTagInput] = useState("");
44
+
45
+
const [color, setColor] = useState(item.color || "yellow");
46
+
47
+
const [title, setTitle] = useState(item.title || item.target?.title || "");
48
+
const [description, setDescription] = useState(item.description || "");
49
+
50
+
const existingLabels = (item.labels || [])
51
+
.filter((l) => l.src === item.author?.did)
52
+
.map((l) => l.val as ContentLabelValue);
53
+
const [selfLabels, setSelfLabels] =
54
+
useState<ContentLabelValue[]>(existingLabels);
55
+
const [showLabelPicker, setShowLabelPicker] = useState(
56
+
existingLabels.length > 0,
57
+
);
58
+
59
+
const [saving, setSaving] = useState(false);
60
+
const [error, setError] = useState<string | null>(null);
61
+
62
+
useEffect(() => {
63
+
if (isOpen) {
64
+
setText(item.body?.value || "");
65
+
setTags(item.tags || []);
66
+
setTagInput("");
67
+
setColor(item.color || "yellow");
68
+
setTitle(item.title || item.target?.title || "");
69
+
setDescription(item.description || "");
70
+
const labels = (item.labels || [])
71
+
.filter((l) => l.src === item.author?.did)
72
+
.map((l) => l.val as ContentLabelValue);
73
+
setSelfLabels(labels);
74
+
setShowLabelPicker(labels.length > 0);
75
+
}
76
+
}, [isOpen, item]);
77
+
78
+
if (!isOpen) return null;
79
+
80
+
const addTag = () => {
81
+
const t = tagInput.trim().toLowerCase();
82
+
if (t && !tags.includes(t)) {
83
+
setTags([...tags, t]);
84
+
}
85
+
setTagInput("");
86
+
};
87
+
88
+
const removeTag = (tag: string) => {
89
+
setTags(tags.filter((t) => t !== tag));
90
+
};
91
+
92
+
const toggleLabel = (val: ContentLabelValue) => {
93
+
setSelfLabels((prev) =>
94
+
prev.includes(val) ? prev.filter((l) => l !== val) : [...prev, val],
95
+
);
96
+
};
97
+
98
+
const handleSave = async () => {
99
+
setSaving(true);
100
+
setError(null);
101
+
let success = false;
102
+
const labels = selfLabels.length > 0 ? selfLabels : [];
103
+
104
+
try {
105
+
if (type === "annotation") {
106
+
success = await updateAnnotation(
107
+
item.uri,
108
+
text,
109
+
tags.length > 0 ? tags : undefined,
110
+
labels,
111
+
);
112
+
} else if (type === "highlight") {
113
+
success = await updateHighlight(
114
+
item.uri,
115
+
color,
116
+
tags.length > 0 ? tags : undefined,
117
+
labels,
118
+
);
119
+
} else if (type === "bookmark") {
120
+
success = await updateBookmark(
121
+
item.uri,
122
+
title || undefined,
123
+
description || undefined,
124
+
tags.length > 0 ? tags : undefined,
125
+
labels,
126
+
);
127
+
}
128
+
} catch (e) {
129
+
console.error("Edit save error:", e);
130
+
setError(e instanceof Error ? e.message : "Failed to save");
131
+
setSaving(false);
132
+
return;
133
+
}
134
+
135
+
setSaving(false);
136
+
if (!success) {
137
+
setError("Failed to save changes. Please try again.");
138
+
return;
139
+
}
140
+
const updated = { ...item };
141
+
if (type === "annotation") {
142
+
updated.body = { type: "TextualBody", value: text, format: "text/plain" };
143
+
} else if (type === "highlight") {
144
+
updated.color = color;
145
+
} else if (type === "bookmark") {
146
+
updated.title = title;
147
+
updated.description = description;
148
+
}
149
+
updated.tags = tags;
150
+
const otherLabels = (item.labels || []).filter(
151
+
(l) => l.src !== item.author?.did,
152
+
);
153
+
const newSelfLabels = selfLabels.map((val) => ({
154
+
val,
155
+
src: item.author?.did || "",
156
+
scope: "content" as const,
157
+
}));
158
+
updated.labels = [...otherLabels, ...newSelfLabels];
159
+
onSaved?.(updated);
160
+
onClose();
161
+
};
162
+
163
+
return (
164
+
<div
165
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
166
+
onClick={onClose}
167
+
>
168
+
<div
169
+
className="bg-white dark:bg-surface-900 rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto"
170
+
onClick={(e) => e.stopPropagation()}
171
+
>
172
+
<div className="flex items-center justify-between px-5 py-4 border-b border-surface-200 dark:border-surface-700">
173
+
<h3 className="text-lg font-semibold text-surface-900 dark:text-surface-100">
174
+
Edit{" "}
175
+
{type === "annotation"
176
+
? "Annotation"
177
+
: type === "highlight"
178
+
? "Highlight"
179
+
: "Bookmark"}
180
+
</h3>
181
+
<button
182
+
onClick={onClose}
183
+
className="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
184
+
>
185
+
<X size={18} />
186
+
</button>
187
+
</div>
188
+
189
+
<div className="px-5 py-4 space-y-4">
190
+
{type === "annotation" && (
191
+
<div>
192
+
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
193
+
Text
194
+
</label>
195
+
<textarea
196
+
value={text}
197
+
onChange={(e) => setText(e.target.value)}
198
+
rows={4}
199
+
maxLength={3000}
200
+
className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
201
+
placeholder="Write your annotation..."
202
+
/>
203
+
<p className="text-xs text-surface-400 mt-1">
204
+
{text.length}/3000
205
+
</p>
206
+
</div>
207
+
)}
208
+
209
+
{type === "highlight" && (
210
+
<div>
211
+
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
212
+
Color
213
+
</label>
214
+
<div className="flex gap-2">
215
+
{HIGHLIGHT_COLORS.map((c) => (
216
+
<button
217
+
key={c.value}
218
+
onClick={() => setColor(c.value)}
219
+
className={`w-8 h-8 rounded-full ${c.bg} transition-all ${
220
+
color === c.value
221
+
? `ring-2 ${c.ring} ring-offset-2 dark:ring-offset-surface-900 scale-110`
222
+
: "opacity-60 hover:opacity-100"
223
+
}`}
224
+
title={c.value}
225
+
/>
226
+
))}
227
+
</div>
228
+
{item.target?.selector?.exact && (
229
+
<blockquote className="mt-3 pl-3 py-2 border-l-2 border-surface-300 dark:border-surface-600 text-sm italic text-surface-500 dark:text-surface-400">
230
+
{item.target.selector.exact}
231
+
</blockquote>
232
+
)}
233
+
</div>
234
+
)}
235
+
236
+
{type === "bookmark" && (
237
+
<>
238
+
<div>
239
+
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
240
+
Title
241
+
</label>
242
+
<input
243
+
type="text"
244
+
value={title}
245
+
onChange={(e) => setTitle(e.target.value)}
246
+
className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
247
+
placeholder="Bookmark title"
248
+
/>
249
+
</div>
250
+
<div>
251
+
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
252
+
Description
253
+
</label>
254
+
<textarea
255
+
value={description}
256
+
onChange={(e) => setDescription(e.target.value)}
257
+
rows={3}
258
+
className="w-full px-3 py-2 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
259
+
placeholder="Optional description..."
260
+
/>
261
+
</div>
262
+
</>
263
+
)}
264
+
265
+
<div>
266
+
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
267
+
Tags
268
+
</label>
269
+
<div className="flex flex-wrap gap-1.5 mb-2">
270
+
{tags.map((tag) => (
271
+
<span
272
+
key={tag}
273
+
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-xs font-medium"
274
+
>
275
+
#{tag}
276
+
<button
277
+
onClick={() => removeTag(tag)}
278
+
className="hover:text-red-500 transition-colors"
279
+
>
280
+
<X size={12} />
281
+
</button>
282
+
</span>
283
+
))}
284
+
</div>
285
+
<div className="flex gap-2">
286
+
<input
287
+
type="text"
288
+
value={tagInput}
289
+
onChange={(e) => setTagInput(e.target.value)}
290
+
onKeyDown={(e) => {
291
+
if (e.key === "Enter") {
292
+
e.preventDefault();
293
+
addTag();
294
+
}
295
+
}}
296
+
className="flex-1 px-3 py-1.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
297
+
placeholder="Add a tag..."
298
+
/>
299
+
<button
300
+
onClick={addTag}
301
+
disabled={!tagInput.trim()}
302
+
className="px-3 py-1.5 rounded-lg bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
303
+
>
304
+
Add
305
+
</button>
306
+
</div>
307
+
</div>
308
+
309
+
<div>
310
+
<button
311
+
onClick={() => setShowLabelPicker(!showLabelPicker)}
312
+
className={`flex items-center gap-2 text-sm font-medium transition-colors ${
313
+
showLabelPicker || selfLabels.length > 0
314
+
? "text-amber-600 dark:text-amber-400"
315
+
: "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200"
316
+
}`}
317
+
>
318
+
<ShieldAlert size={16} />
319
+
Content Warning
320
+
{selfLabels.length > 0 && (
321
+
<span className="text-xs bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 px-1.5 py-0.5 rounded-full">
322
+
{selfLabels.length}
323
+
</span>
324
+
)}
325
+
</button>
326
+
{showLabelPicker && (
327
+
<div className="flex flex-wrap gap-1.5 mt-2">
328
+
{SELF_LABEL_OPTIONS.map((opt) => (
329
+
<button
330
+
key={opt.value}
331
+
onClick={() => toggleLabel(opt.value)}
332
+
className={`px-3 py-1 rounded-full text-xs font-medium border transition-all ${
333
+
selfLabels.includes(opt.value)
334
+
? "bg-amber-100 dark:bg-amber-900/40 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200"
335
+
: "bg-surface-50 dark:bg-surface-800 border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:border-amber-300 dark:hover:border-amber-700"
336
+
}`}
337
+
>
338
+
{opt.label}
339
+
</button>
340
+
))}
341
+
</div>
342
+
)}
343
+
</div>
344
+
</div>
345
+
346
+
<div className="px-5 py-4 border-t border-surface-200 dark:border-surface-700">
347
+
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
348
+
<div className="flex items-center justify-end gap-2">
349
+
<button
350
+
onClick={onClose}
351
+
className="px-4 py-2 rounded-xl text-sm font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
352
+
>
353
+
Cancel
354
+
</button>
355
+
<button
356
+
onClick={handleSave}
357
+
disabled={saving || (type === "annotation" && !text.trim())}
358
+
className="px-4 py-2 rounded-xl bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
359
+
>
360
+
{saving ? "Saving..." : "Save"}
361
+
</button>
362
+
</div>
363
+
</div>
364
+
</div>
365
+
</div>
366
+
);
367
+
}
+208
web/src/components/modals/ReportModal.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import React, { useState } from "react";
2
+
import { Flag, X } from "lucide-react";
3
+
import { reportUser } from "../../api/client";
4
+
import type { ReportReasonType } from "../../types";
5
+
6
+
interface ReportModalProps {
7
+
isOpen: boolean;
8
+
onClose: () => void;
9
+
subjectDid: string;
10
+
subjectUri?: string;
11
+
subjectHandle?: string;
12
+
}
13
+
14
+
const REASONS: {
15
+
value: ReportReasonType;
16
+
label: string;
17
+
description: string;
18
+
}[] = [
19
+
{ value: "spam", label: "Spam", description: "Unwanted repetitive content" },
20
+
{
21
+
value: "violation",
22
+
label: "Rule violation",
23
+
description: "Violates community guidelines",
24
+
},
25
+
{
26
+
value: "misleading",
27
+
label: "Misleading",
28
+
description: "False or misleading information",
29
+
},
30
+
{
31
+
value: "rude",
32
+
label: "Rude or harassing",
33
+
description: "Targeting or harassing a user",
34
+
},
35
+
{
36
+
value: "sexual",
37
+
label: "Inappropriate content",
38
+
description: "Sexual or explicit material",
39
+
},
40
+
{
41
+
value: "other",
42
+
label: "Other",
43
+
description: "Something else not listed above",
44
+
},
45
+
];
46
+
47
+
export default function ReportModal({
48
+
isOpen,
49
+
onClose,
50
+
subjectDid,
51
+
subjectUri,
52
+
subjectHandle,
53
+
}: ReportModalProps) {
54
+
const [selectedReason, setSelectedReason] = useState<ReportReasonType | null>(
55
+
null,
56
+
);
57
+
const [additionalText, setAdditionalText] = useState("");
58
+
const [submitting, setSubmitting] = useState(false);
59
+
const [submitted, setSubmitted] = useState(false);
60
+
61
+
if (!isOpen) return null;
62
+
63
+
const handleSubmit = async () => {
64
+
if (!selectedReason) return;
65
+
66
+
setSubmitting(true);
67
+
const success = await reportUser({
68
+
subjectDid: subjectDid,
69
+
subjectUri: subjectUri,
70
+
reasonType: selectedReason,
71
+
reasonText: additionalText || undefined,
72
+
});
73
+
74
+
setSubmitting(false);
75
+
if (success) {
76
+
setSubmitted(true);
77
+
setTimeout(() => {
78
+
onClose();
79
+
setSubmitted(false);
80
+
setSelectedReason(null);
81
+
setAdditionalText("");
82
+
}, 1500);
83
+
}
84
+
};
85
+
86
+
const handleClose = () => {
87
+
onClose();
88
+
setSelectedReason(null);
89
+
setAdditionalText("");
90
+
setSubmitted(false);
91
+
};
92
+
93
+
return (
94
+
<div
95
+
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
96
+
onClick={handleClose}
97
+
>
98
+
<div
99
+
className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl border border-surface-200 dark:border-surface-700 w-full max-w-md mx-4 overflow-hidden"
100
+
onClick={(e) => e.stopPropagation()}
101
+
>
102
+
{submitted ? (
103
+
<div className="p-8 text-center">
104
+
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-3">
105
+
<Flag size={20} className="text-green-600 dark:text-green-400" />
106
+
</div>
107
+
<h3 className="text-lg font-semibold text-surface-900 dark:text-white">
108
+
Report submitted
109
+
</h3>
110
+
<p className="text-surface-500 dark:text-surface-400 text-sm mt-1">
111
+
Thank you. We'll review this shortly.
112
+
</p>
113
+
</div>
114
+
) : (
115
+
<>
116
+
<div className="flex items-center justify-between p-4 border-b border-surface-200 dark:border-surface-700">
117
+
<div className="flex items-center gap-2.5">
118
+
<div className="w-8 h-8 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
119
+
<Flag size={16} className="text-red-600 dark:text-red-400" />
120
+
</div>
121
+
<div>
122
+
<h3 className="text-base font-semibold text-surface-900 dark:text-white">
123
+
Report {subjectHandle ? `@${subjectHandle}` : "user"}
124
+
</h3>
125
+
{subjectUri && (
126
+
<p className="text-xs text-surface-400 dark:text-surface-500">
127
+
Reporting specific content
128
+
</p>
129
+
)}
130
+
</div>
131
+
</div>
132
+
<button
133
+
onClick={handleClose}
134
+
className="p-1.5 text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
135
+
>
136
+
<X size={18} />
137
+
</button>
138
+
</div>
139
+
140
+
<div className="p-4 space-y-2">
141
+
<p className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3">
142
+
What's the issue?
143
+
</p>
144
+
{REASONS.map((reason) => (
145
+
<button
146
+
key={reason.value}
147
+
onClick={() => setSelectedReason(reason.value)}
148
+
className={`w-full text-left px-3.5 py-2.5 rounded-xl border transition-all ${
149
+
selectedReason === reason.value
150
+
? "border-primary-500 bg-primary-50 dark:bg-primary-900/20"
151
+
: "border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600"
152
+
}`}
153
+
>
154
+
<span
155
+
className={`text-sm font-medium ${
156
+
selectedReason === reason.value
157
+
? "text-primary-700 dark:text-primary-300"
158
+
: "text-surface-800 dark:text-surface-200"
159
+
}`}
160
+
>
161
+
{reason.label}
162
+
</span>
163
+
<span
164
+
className={`block text-xs mt-0.5 ${
165
+
selectedReason === reason.value
166
+
? "text-primary-600/70 dark:text-primary-400/70"
167
+
: "text-surface-400 dark:text-surface-500"
168
+
}`}
169
+
>
170
+
{reason.description}
171
+
</span>
172
+
</button>
173
+
))}
174
+
</div>
175
+
176
+
{selectedReason && (
177
+
<div className="px-4 pb-2">
178
+
<textarea
179
+
value={additionalText}
180
+
onChange={(e) => setAdditionalText(e.target.value)}
181
+
placeholder="Additional details (optional)"
182
+
rows={2}
183
+
className="w-full px-3.5 py-2.5 text-sm rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-800 dark:text-surface-200 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500 resize-none"
184
+
/>
185
+
</div>
186
+
)}
187
+
188
+
<div className="flex items-center justify-end gap-2 p-4 border-t border-surface-200 dark:border-surface-700">
189
+
<button
190
+
onClick={handleClose}
191
+
className="px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
192
+
>
193
+
Cancel
194
+
</button>
195
+
<button
196
+
onClick={handleSubmit}
197
+
disabled={!selectedReason || submitting}
198
+
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
199
+
>
200
+
{submitting ? "Submitting…" : "Submit Report"}
201
+
</button>
202
+
</div>
203
+
</>
204
+
)}
205
+
</div>
206
+
</div>
207
+
);
208
+
}
+69
-7
web/src/store/preferences.ts
···
1
import { atom } from "nanostores";
2
import { getPreferences, updatePreferences } from "../api/client";
0
0
0
0
0
3
4
export interface Preferences {
5
externalLinkSkippedHostnames: string[];
0
0
6
}
7
8
export const $preferences = atom<Preferences>({
9
externalLinkSkippedHostnames: [],
0
0
10
});
11
12
export async function loadPreferences() {
13
const prefs = await getPreferences();
14
$preferences.set({
15
externalLinkSkippedHostnames: prefs.externalLinkSkippedHostnames || [],
0
0
16
});
17
}
18
···
20
const current = $preferences.get();
21
if (current.externalLinkSkippedHostnames.includes(hostname)) return;
22
23
-
const newHostnames = [...current.externalLinkSkippedHostnames, hostname];
24
-
$preferences.set({
25
...current,
26
-
externalLinkSkippedHostnames: newHostnames,
27
-
});
0
0
0
0
0
0
28
29
-
await updatePreferences({
30
-
externalLinkSkippedHostnames: newHostnames,
31
-
});
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
32
}
···
1
import { atom } from "nanostores";
2
import { getPreferences, updatePreferences } from "../api/client";
3
+
import type {
4
+
LabelerSubscription,
5
+
LabelPreference,
6
+
LabelVisibility,
7
+
} from "../types";
8
9
export interface Preferences {
10
externalLinkSkippedHostnames: string[];
11
+
subscribedLabelers: LabelerSubscription[];
12
+
labelPreferences: LabelPreference[];
13
}
14
15
export const $preferences = atom<Preferences>({
16
externalLinkSkippedHostnames: [],
17
+
subscribedLabelers: [],
18
+
labelPreferences: [],
19
});
20
21
export async function loadPreferences() {
22
const prefs = await getPreferences();
23
$preferences.set({
24
externalLinkSkippedHostnames: prefs.externalLinkSkippedHostnames || [],
25
+
subscribedLabelers: prefs.subscribedLabelers || [],
26
+
labelPreferences: prefs.labelPreferences || [],
27
});
28
}
29
···
31
const current = $preferences.get();
32
if (current.externalLinkSkippedHostnames.includes(hostname)) return;
33
34
+
const updated = {
0
35
...current,
36
+
externalLinkSkippedHostnames: [
37
+
...current.externalLinkSkippedHostnames,
38
+
hostname,
39
+
],
40
+
};
41
+
$preferences.set(updated);
42
+
await updatePreferences(updated);
43
+
}
44
45
+
export async function addLabeler(did: string) {
46
+
const current = $preferences.get();
47
+
if (current.subscribedLabelers.some((l) => l.did === did)) return;
48
+
49
+
const updated = {
50
+
...current,
51
+
subscribedLabelers: [...current.subscribedLabelers, { did }],
52
+
};
53
+
$preferences.set(updated);
54
+
await updatePreferences(updated);
55
+
}
56
+
57
+
export async function removeLabeler(did: string) {
58
+
const current = $preferences.get();
59
+
const updated = {
60
+
...current,
61
+
subscribedLabelers: current.subscribedLabelers.filter((l) => l.did !== did),
62
+
};
63
+
$preferences.set(updated);
64
+
await updatePreferences(updated);
65
+
}
66
+
67
+
export async function setLabelVisibility(
68
+
labelerDid: string,
69
+
label: string,
70
+
visibility: LabelVisibility,
71
+
) {
72
+
const current = $preferences.get();
73
+
const filtered = current.labelPreferences.filter(
74
+
(p) => !(p.labelerDid === labelerDid && p.label === label),
75
+
);
76
+
const newPrefs =
77
+
visibility === "warn"
78
+
? filtered
79
+
: [...filtered, { labelerDid, label, visibility }];
80
+
const updated = { ...current, labelPreferences: newPrefs };
81
+
$preferences.set(updated);
82
+
await updatePreferences(updated);
83
+
}
84
+
85
+
export function getLabelVisibility(
86
+
labelerDid: string,
87
+
label: string,
88
+
): LabelVisibility {
89
+
const prefs = $preferences.get();
90
+
const pref = prefs.labelPreferences.find(
91
+
(p) => p.labelerDid === labelerDid && p.label === label,
92
+
);
93
+
return pref?.visibility || "warn";
94
}
+80
web/src/types.ts
···
10
followersCount?: number;
11
followsCount?: number;
12
postsCount?: number;
0
13
}
14
15
export interface Selector {
···
80
};
81
};
82
parentUri?: string;
0
83
}
84
85
export type ActorSearchItem = UserProfile;
···
134
text: string;
135
createdAt: string;
136
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
10
followersCount?: number;
11
followsCount?: number;
12
postsCount?: number;
13
+
labels?: ContentLabel[];
14
}
15
16
export interface Selector {
···
81
};
82
};
83
parentUri?: string;
84
+
labels?: ContentLabel[];
85
}
86
87
export type ActorSearchItem = UserProfile;
···
136
text: string;
137
createdAt: string;
138
}
139
+
140
+
export interface ModerationRelationship {
141
+
blocking: boolean;
142
+
muting: boolean;
143
+
blockedBy: boolean;
144
+
}
145
+
146
+
export interface BlockedUser {
147
+
did: string;
148
+
author: UserProfile;
149
+
createdAt: string;
150
+
}
151
+
152
+
export interface MutedUser {
153
+
did: string;
154
+
author: UserProfile;
155
+
createdAt: string;
156
+
}
157
+
158
+
export interface ModerationReport {
159
+
id: number;
160
+
reporter: UserProfile;
161
+
subject: UserProfile;
162
+
subjectUri?: string;
163
+
reasonType: string;
164
+
reasonText?: string;
165
+
status: string;
166
+
createdAt: string;
167
+
resolvedAt?: string;
168
+
resolvedBy?: string;
169
+
}
170
+
171
+
export type ReportReasonType =
172
+
| "spam"
173
+
| "violation"
174
+
| "misleading"
175
+
| "sexual"
176
+
| "rude"
177
+
| "other";
178
+
179
+
export interface ContentLabel {
180
+
val: string;
181
+
src: string;
182
+
scope?: "account" | "content";
183
+
}
184
+
185
+
export type ContentLabelValue =
186
+
| "sexual"
187
+
| "nudity"
188
+
| "violence"
189
+
| "gore"
190
+
| "spam"
191
+
| "misleading";
192
+
193
+
export type LabelVisibility = "hide" | "warn" | "ignore";
194
+
195
+
export interface LabelerSubscription {
196
+
did: string;
197
+
}
198
+
199
+
export interface LabelPreference {
200
+
labelerDid: string;
201
+
label: string;
202
+
visibility: LabelVisibility;
203
+
}
204
+
205
+
export interface LabelDefinition {
206
+
identifier: string;
207
+
severity: string;
208
+
blurs: string;
209
+
description: string;
210
+
}
211
+
212
+
export interface LabelerInfo {
213
+
did: string;
214
+
name: string;
215
+
labels: LabelDefinition[];
216
+
}
+563
web/src/views/core/AdminModeration.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import React, { useEffect, useState } from "react";
2
+
import { useStore } from "@nanostores/react";
3
+
import { $user } from "../../store/auth";
4
+
import {
5
+
checkAdminAccess,
6
+
getAdminReports,
7
+
adminTakeAction,
8
+
adminCreateLabel,
9
+
adminDeleteLabel,
10
+
adminGetLabels,
11
+
} from "../../api/client";
12
+
import type { ModerationReport } from "../../types";
13
+
import {
14
+
Shield,
15
+
CheckCircle,
16
+
XCircle,
17
+
AlertTriangle,
18
+
Eye,
19
+
ChevronDown,
20
+
ChevronUp,
21
+
Tag,
22
+
FileText,
23
+
Plus,
24
+
Trash2,
25
+
EyeOff,
26
+
} from "lucide-react";
27
+
import { Avatar, EmptyState, Skeleton, Button } from "../../components/ui";
28
+
import { Link } from "react-router-dom";
29
+
30
+
const STATUS_COLORS: Record<string, string> = {
31
+
pending:
32
+
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
33
+
resolved:
34
+
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
35
+
dismissed:
36
+
"bg-surface-100 text-surface-600 dark:bg-surface-800 dark:text-surface-400",
37
+
escalated: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
38
+
acknowledged:
39
+
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
40
+
};
41
+
42
+
const REASON_LABELS: Record<string, string> = {
43
+
spam: "Spam",
44
+
violation: "Rule Violation",
45
+
misleading: "Misleading",
46
+
sexual: "Inappropriate",
47
+
rude: "Rude / Harassing",
48
+
other: "Other",
49
+
};
50
+
51
+
const LABEL_OPTIONS = [
52
+
{ val: "sexual", label: "Sexual Content" },
53
+
{ val: "nudity", label: "Nudity" },
54
+
{ val: "violence", label: "Violence" },
55
+
{ val: "gore", label: "Graphic Content" },
56
+
{ val: "spam", label: "Spam" },
57
+
{ val: "misleading", label: "Misleading" },
58
+
];
59
+
60
+
interface HydratedLabel {
61
+
id: number;
62
+
src: string;
63
+
uri: string;
64
+
val: string;
65
+
createdBy: {
66
+
did: string;
67
+
handle: string;
68
+
displayName?: string;
69
+
avatar?: string;
70
+
};
71
+
createdAt: string;
72
+
subject?: {
73
+
did: string;
74
+
handle: string;
75
+
displayName?: string;
76
+
avatar?: string;
77
+
};
78
+
}
79
+
80
+
type Tab = "reports" | "labels" | "actions";
81
+
82
+
export default function AdminModeration() {
83
+
const user = useStore($user);
84
+
const [isAdmin, setIsAdmin] = useState(false);
85
+
const [loading, setLoading] = useState(true);
86
+
const [activeTab, setActiveTab] = useState<Tab>("reports");
87
+
88
+
const [reports, setReports] = useState<ModerationReport[]>([]);
89
+
const [pendingCount, setPendingCount] = useState(0);
90
+
const [totalCount, setTotalCount] = useState(0);
91
+
const [statusFilter, setStatusFilter] = useState<string>("pending");
92
+
const [expandedReport, setExpandedReport] = useState<number | null>(null);
93
+
const [actionLoading, setActionLoading] = useState<number | null>(null);
94
+
95
+
const [labels, setLabels] = useState<HydratedLabel[]>([]);
96
+
97
+
const [labelSrc, setLabelSrc] = useState("");
98
+
const [labelUri, setLabelUri] = useState("");
99
+
const [labelVal, setLabelVal] = useState("");
100
+
const [labelSubmitting, setLabelSubmitting] = useState(false);
101
+
const [labelSuccess, setLabelSuccess] = useState(false);
102
+
103
+
useEffect(() => {
104
+
const init = async () => {
105
+
const admin = await checkAdminAccess();
106
+
setIsAdmin(admin);
107
+
if (admin) await loadReports("pending");
108
+
setLoading(false);
109
+
};
110
+
init();
111
+
}, []);
112
+
113
+
const loadReports = async (status: string) => {
114
+
const data = await getAdminReports(status || undefined);
115
+
setReports(data.items);
116
+
setPendingCount(data.pendingCount);
117
+
setTotalCount(data.totalItems);
118
+
};
119
+
120
+
const loadLabels = async () => {
121
+
const data = await adminGetLabels();
122
+
setLabels(data.items || []);
123
+
};
124
+
125
+
const handleTabChange = async (tab: Tab) => {
126
+
setActiveTab(tab);
127
+
if (tab === "labels") await loadLabels();
128
+
};
129
+
130
+
const handleFilterChange = async (status: string) => {
131
+
setStatusFilter(status);
132
+
await loadReports(status);
133
+
};
134
+
135
+
const handleAction = async (reportId: number, action: string) => {
136
+
setActionLoading(reportId);
137
+
const success = await adminTakeAction({ reportId, action });
138
+
if (success) {
139
+
await loadReports(statusFilter);
140
+
setExpandedReport(null);
141
+
}
142
+
setActionLoading(null);
143
+
};
144
+
145
+
const handleCreateLabel = async () => {
146
+
if (!labelVal || (!labelSrc && !labelUri)) return;
147
+
setLabelSubmitting(true);
148
+
const success = await adminCreateLabel({
149
+
src: labelSrc || labelUri,
150
+
uri: labelUri || undefined,
151
+
val: labelVal,
152
+
});
153
+
if (success) {
154
+
setLabelSrc("");
155
+
setLabelUri("");
156
+
setLabelVal("");
157
+
setLabelSuccess(true);
158
+
setTimeout(() => setLabelSuccess(false), 2000);
159
+
if (activeTab === "labels") await loadLabels();
160
+
}
161
+
setLabelSubmitting(false);
162
+
};
163
+
164
+
const handleDeleteLabel = async (id: number) => {
165
+
if (!window.confirm("Remove this label?")) return;
166
+
const success = await adminDeleteLabel(id);
167
+
if (success) setLabels((prev) => prev.filter((l) => l.id !== id));
168
+
};
169
+
170
+
if (loading) {
171
+
return (
172
+
<div className="max-w-3xl mx-auto animate-slide-up">
173
+
<Skeleton className="h-8 w-48 mb-6" />
174
+
<div className="space-y-3">
175
+
<Skeleton className="h-24 rounded-xl" />
176
+
<Skeleton className="h-24 rounded-xl" />
177
+
<Skeleton className="h-24 rounded-xl" />
178
+
</div>
179
+
</div>
180
+
);
181
+
}
182
+
183
+
if (!user || !isAdmin) {
184
+
return (
185
+
<EmptyState
186
+
icon={<Shield size={40} />}
187
+
title="Access Denied"
188
+
message="You don't have permission to access the moderation dashboard."
189
+
/>
190
+
);
191
+
}
192
+
193
+
return (
194
+
<div className="max-w-3xl mx-auto animate-slide-up">
195
+
<div className="flex items-center justify-between mb-6">
196
+
<div>
197
+
<h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white flex items-center gap-2.5">
198
+
<Shield
199
+
size={24}
200
+
className="text-primary-600 dark:text-primary-400"
201
+
/>
202
+
Moderation
203
+
</h1>
204
+
<p className="text-sm text-surface-500 dark:text-surface-400 mt-1">
205
+
{pendingCount} pending · {totalCount} total reports
206
+
</p>
207
+
</div>
208
+
</div>
209
+
210
+
<div className="flex gap-1 mb-5 border-b border-surface-200 dark:border-surface-700">
211
+
{[
212
+
{
213
+
id: "reports" as Tab,
214
+
label: "Reports",
215
+
icon: <FileText size={15} />,
216
+
},
217
+
{
218
+
id: "actions" as Tab,
219
+
label: "Actions",
220
+
icon: <EyeOff size={15} />,
221
+
},
222
+
{ id: "labels" as Tab, label: "Labels", icon: <Tag size={15} /> },
223
+
].map((tab) => (
224
+
<button
225
+
key={tab.id}
226
+
onClick={() => handleTabChange(tab.id)}
227
+
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
228
+
activeTab === tab.id
229
+
? "border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400"
230
+
: "border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300"
231
+
}`}
232
+
>
233
+
{tab.icon}
234
+
{tab.label}
235
+
</button>
236
+
))}
237
+
</div>
238
+
239
+
{activeTab === "reports" && (
240
+
<>
241
+
<div className="flex gap-2 mb-5">
242
+
{["pending", "resolved", "dismissed", "escalated", ""].map(
243
+
(status) => (
244
+
<button
245
+
key={status || "all"}
246
+
onClick={() => handleFilterChange(status)}
247
+
className={`px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors ${
248
+
statusFilter === status
249
+
? "bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
250
+
: "text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800"
251
+
}`}
252
+
>
253
+
{status
254
+
? status.charAt(0).toUpperCase() + status.slice(1)
255
+
: "All"}
256
+
</button>
257
+
),
258
+
)}
259
+
</div>
260
+
261
+
{reports.length === 0 ? (
262
+
<EmptyState
263
+
icon={<CheckCircle size={40} />}
264
+
title="No reports"
265
+
message={
266
+
statusFilter === "pending"
267
+
? "No pending reports to review."
268
+
: `No ${statusFilter || ""} reports found.`
269
+
}
270
+
/>
271
+
) : (
272
+
<div className="space-y-3">
273
+
{reports.map((report) => (
274
+
<div
275
+
key={report.id}
276
+
className="card overflow-hidden transition-all"
277
+
>
278
+
<button
279
+
onClick={() =>
280
+
setExpandedReport(
281
+
expandedReport === report.id ? null : report.id,
282
+
)
283
+
}
284
+
className="w-full p-4 flex items-center gap-4 text-left hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors"
285
+
>
286
+
<Avatar
287
+
did={report.subject.did}
288
+
avatar={report.subject.avatar}
289
+
size="sm"
290
+
/>
291
+
<div className="flex-1 min-w-0">
292
+
<div className="flex items-center gap-2 mb-0.5">
293
+
<span className="font-medium text-surface-900 dark:text-white text-sm truncate">
294
+
{report.subject.displayName ||
295
+
report.subject.handle ||
296
+
report.subject.did}
297
+
</span>
298
+
<span
299
+
className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[report.status] || STATUS_COLORS.pending}`}
300
+
>
301
+
{report.status}
302
+
</span>
303
+
</div>
304
+
<p className="text-xs text-surface-500 dark:text-surface-400">
305
+
{REASON_LABELS[report.reasonType] || report.reasonType}{" "}
306
+
· reported by @
307
+
{report.reporter.handle || report.reporter.did} ·{" "}
308
+
{new Date(report.createdAt).toLocaleDateString()}
309
+
</p>
310
+
</div>
311
+
{expandedReport === report.id ? (
312
+
<ChevronUp size={16} className="text-surface-400" />
313
+
) : (
314
+
<ChevronDown size={16} className="text-surface-400" />
315
+
)}
316
+
</button>
317
+
318
+
{expandedReport === report.id && (
319
+
<div className="px-4 pb-4 border-t border-surface-100 dark:border-surface-800 pt-3 space-y-3">
320
+
<div className="grid grid-cols-2 gap-3 text-sm">
321
+
<div>
322
+
<span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
323
+
Reported User
324
+
</span>
325
+
<Link
326
+
to={`/profile/${report.subject.did}`}
327
+
className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium"
328
+
>
329
+
@{report.subject.handle || report.subject.did}
330
+
</Link>
331
+
</div>
332
+
<div>
333
+
<span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
334
+
Reporter
335
+
</span>
336
+
<Link
337
+
to={`/profile/${report.reporter.did}`}
338
+
className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium"
339
+
>
340
+
@{report.reporter.handle || report.reporter.did}
341
+
</Link>
342
+
</div>
343
+
</div>
344
+
345
+
{report.reasonText && (
346
+
<div>
347
+
<span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
348
+
Details
349
+
</span>
350
+
<p className="text-sm text-surface-700 dark:text-surface-300 mt-1">
351
+
{report.reasonText}
352
+
</p>
353
+
</div>
354
+
)}
355
+
356
+
{report.subjectUri && (
357
+
<div>
358
+
<span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
359
+
Content URI
360
+
</span>
361
+
<p className="text-xs text-surface-500 font-mono mt-1 break-all">
362
+
{report.subjectUri}
363
+
</p>
364
+
</div>
365
+
)}
366
+
367
+
{report.status === "pending" && (
368
+
<div className="flex items-center gap-2 pt-2">
369
+
<Button
370
+
size="sm"
371
+
variant="secondary"
372
+
onClick={() =>
373
+
handleAction(report.id, "acknowledge")
374
+
}
375
+
loading={actionLoading === report.id}
376
+
icon={<Eye size={14} />}
377
+
>
378
+
Acknowledge
379
+
</Button>
380
+
<Button
381
+
size="sm"
382
+
variant="secondary"
383
+
onClick={() => handleAction(report.id, "dismiss")}
384
+
loading={actionLoading === report.id}
385
+
icon={<XCircle size={14} />}
386
+
>
387
+
Dismiss
388
+
</Button>
389
+
<Button
390
+
size="sm"
391
+
onClick={() => handleAction(report.id, "takedown")}
392
+
loading={actionLoading === report.id}
393
+
icon={<AlertTriangle size={14} />}
394
+
className="!bg-red-600 hover:!bg-red-700 !text-white"
395
+
>
396
+
Takedown
397
+
</Button>
398
+
</div>
399
+
)}
400
+
</div>
401
+
)}
402
+
</div>
403
+
))}
404
+
</div>
405
+
)}
406
+
</>
407
+
)}
408
+
409
+
{activeTab === "actions" && (
410
+
<div className="space-y-6">
411
+
<div className="card p-5">
412
+
<h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2">
413
+
<Tag
414
+
size={16}
415
+
className="text-primary-600 dark:text-primary-400"
416
+
/>
417
+
Apply Content Warning
418
+
</h3>
419
+
<p className="text-sm text-surface-500 dark:text-surface-400 mb-4">
420
+
Add a content warning label to a specific post or account. Users
421
+
will see a blur overlay with the option to reveal.
422
+
</p>
423
+
424
+
<div className="space-y-3">
425
+
<div>
426
+
<label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
427
+
Account DID
428
+
</label>
429
+
<input
430
+
type="text"
431
+
value={labelSrc}
432
+
onChange={(e) => setLabelSrc(e.target.value)}
433
+
placeholder="did:plc:..."
434
+
className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
435
+
/>
436
+
</div>
437
+
438
+
<div>
439
+
<label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
440
+
Content URI{" "}
441
+
<span className="text-surface-400">
442
+
(optional — leave empty for account-level label)
443
+
</span>
444
+
</label>
445
+
<input
446
+
type="text"
447
+
value={labelUri}
448
+
onChange={(e) => setLabelUri(e.target.value)}
449
+
placeholder="at://did:plc:.../at.margin.annotation/..."
450
+
className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
451
+
/>
452
+
</div>
453
+
454
+
<div>
455
+
<label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
456
+
Label Type
457
+
</label>
458
+
<div className="grid grid-cols-3 gap-2">
459
+
{LABEL_OPTIONS.map((opt) => (
460
+
<button
461
+
key={opt.val}
462
+
onClick={() => setLabelVal(opt.val)}
463
+
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-all ${
464
+
labelVal === opt.val
465
+
? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 ring-2 ring-primary-500/20"
466
+
: "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800"
467
+
}`}
468
+
>
469
+
{opt.label}
470
+
</button>
471
+
))}
472
+
</div>
473
+
</div>
474
+
475
+
<div className="flex items-center gap-3 pt-1">
476
+
<Button
477
+
onClick={handleCreateLabel}
478
+
loading={labelSubmitting}
479
+
disabled={!labelVal || (!labelSrc && !labelUri)}
480
+
icon={<Plus size={14} />}
481
+
size="sm"
482
+
>
483
+
Apply Label
484
+
</Button>
485
+
{labelSuccess && (
486
+
<span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5">
487
+
<CheckCircle size={14} /> Label applied
488
+
</span>
489
+
)}
490
+
</div>
491
+
</div>
492
+
</div>
493
+
</div>
494
+
)}
495
+
496
+
{activeTab === "labels" && (
497
+
<div>
498
+
{labels.length === 0 ? (
499
+
<EmptyState
500
+
icon={<Tag size={40} />}
501
+
title="No labels"
502
+
message="No content labels have been applied yet."
503
+
/>
504
+
) : (
505
+
<div className="space-y-2">
506
+
{labels.map((label) => (
507
+
<div
508
+
key={label.id}
509
+
className="card p-4 flex items-center gap-4"
510
+
>
511
+
{label.subject && (
512
+
<Avatar
513
+
did={label.subject.did}
514
+
avatar={label.subject.avatar}
515
+
size="sm"
516
+
/>
517
+
)}
518
+
<div className="flex-1 min-w-0">
519
+
<div className="flex items-center gap-2 mb-0.5">
520
+
<span
521
+
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
522
+
label.val === "sexual" || label.val === "nudity"
523
+
? "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300"
524
+
: label.val === "violence" || label.val === "gore"
525
+
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
526
+
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
527
+
}`}
528
+
>
529
+
{label.val}
530
+
</span>
531
+
{label.subject && (
532
+
<Link
533
+
to={`/profile/${label.subject.did}`}
534
+
className="text-sm font-medium text-surface-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 truncate"
535
+
>
536
+
@{label.subject.handle || label.subject.did}
537
+
</Link>
538
+
)}
539
+
</div>
540
+
<p className="text-xs text-surface-500 dark:text-surface-400 truncate">
541
+
{label.uri !== label.src
542
+
? label.uri
543
+
: "Account-level label"}{" "}
544
+
· {new Date(label.createdAt).toLocaleDateString()} · by @
545
+
{label.createdBy.handle || label.createdBy.did}
546
+
</p>
547
+
</div>
548
+
<button
549
+
onClick={() => handleDeleteLabel(label.id)}
550
+
className="p-2 rounded-lg text-surface-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
551
+
title="Remove label"
552
+
>
553
+
<Trash2 size={14} />
554
+
</button>
555
+
</div>
556
+
))}
557
+
</div>
558
+
)}
559
+
</div>
560
+
)}
561
+
</div>
562
+
);
563
+
}
+360
web/src/views/core/Settings.tsx
···
3
import { $user, logout } from "../../store/auth";
4
import { $theme, setTheme, type Theme } from "../../store/theme";
5
import {
0
0
0
0
0
0
0
0
6
getAPIKeys,
7
createAPIKey,
8
deleteAPIKey,
0
0
0
0
0
9
type APIKey,
10
} from "../../api/client";
0
0
0
0
0
0
0
11
import {
12
Copy,
13
Trash2,
···
19
Monitor,
20
LogOut,
21
ChevronRight,
0
0
0
0
0
0
0
0
22
} from "lucide-react";
23
import {
24
Avatar,
···
28
EmptyState,
29
} from "../../components/ui";
30
import { AppleIcon } from "../../components/common/Icons";
0
31
32
export default function Settings() {
33
const user = useStore($user);
···
38
const [createdKey, setCreatedKey] = useState<string | null>(null);
39
const [justCopied, setJustCopied] = useState(false);
40
const [creating, setCreating] = useState(false);
0
0
0
0
0
0
0
41
42
useEffect(() => {
43
const loadKeys = async () => {
···
47
setLoading(false);
48
};
49
loadKeys();
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
50
}, []);
51
52
const handleCreate = async (e: React.FormEvent) => {
···
247
))}
248
</div>
249
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
250
</section>
251
252
<section className="card p-5">
···
3
import { $user, logout } from "../../store/auth";
4
import { $theme, setTheme, type Theme } from "../../store/theme";
5
import {
6
+
$preferences,
7
+
loadPreferences,
8
+
addLabeler,
9
+
removeLabeler,
10
+
setLabelVisibility,
11
+
getLabelVisibility,
12
+
} from "../../store/preferences";
13
+
import {
14
getAPIKeys,
15
createAPIKey,
16
deleteAPIKey,
17
+
getBlocks,
18
+
getMutes,
19
+
unblockUser,
20
+
unmuteUser,
21
+
getLabelerInfo,
22
type APIKey,
23
} from "../../api/client";
24
+
import type {
25
+
BlockedUser,
26
+
MutedUser,
27
+
LabelerInfo,
28
+
LabelVisibility as LabelVisibilityType,
29
+
ContentLabelValue,
30
+
} from "../../types";
31
import {
32
Copy,
33
Trash2,
···
39
Monitor,
40
LogOut,
41
ChevronRight,
42
+
ShieldBan,
43
+
VolumeX,
44
+
ShieldOff,
45
+
Volume2,
46
+
Shield,
47
+
Eye,
48
+
EyeOff,
49
+
XCircle,
50
} from "lucide-react";
51
import {
52
Avatar,
···
56
EmptyState,
57
} from "../../components/ui";
58
import { AppleIcon } from "../../components/common/Icons";
59
+
import { Link } from "react-router-dom";
60
61
export default function Settings() {
62
const user = useStore($user);
···
67
const [createdKey, setCreatedKey] = useState<string | null>(null);
68
const [justCopied, setJustCopied] = useState(false);
69
const [creating, setCreating] = useState(false);
70
+
const [blocks, setBlocks] = useState<BlockedUser[]>([]);
71
+
const [mutes, setMutes] = useState<MutedUser[]>([]);
72
+
const [modLoading, setModLoading] = useState(true);
73
+
const [labelerInfo, setLabelerInfo] = useState<LabelerInfo | null>(null);
74
+
const [newLabelerDid, setNewLabelerDid] = useState("");
75
+
const [addingLabeler, setAddingLabeler] = useState(false);
76
+
const preferences = useStore($preferences);
77
78
useEffect(() => {
79
const loadKeys = async () => {
···
83
setLoading(false);
84
};
85
loadKeys();
86
+
87
+
const loadModeration = async () => {
88
+
setModLoading(true);
89
+
const [blocksData, mutesData] = await Promise.all([
90
+
getBlocks(),
91
+
getMutes(),
92
+
]);
93
+
setBlocks(blocksData);
94
+
setMutes(mutesData);
95
+
setModLoading(false);
96
+
};
97
+
loadModeration();
98
+
99
+
loadPreferences();
100
+
getLabelerInfo().then(setLabelerInfo);
101
}, []);
102
103
const handleCreate = async (e: React.FormEvent) => {
···
298
))}
299
</div>
300
)}
301
+
</section>
302
+
303
+
<section className="card p-5">
304
+
<h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">
305
+
Moderation
306
+
</h2>
307
+
<p className="text-sm text-surface-400 dark:text-surface-500 mb-5">
308
+
Manage blocked and muted accounts
309
+
</p>
310
+
311
+
{modLoading ? (
312
+
<div className="space-y-3">
313
+
<Skeleton className="h-14 rounded-xl" />
314
+
<Skeleton className="h-14 rounded-xl" />
315
+
</div>
316
+
) : (
317
+
<div className="space-y-4">
318
+
<div>
319
+
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2">
320
+
<ShieldBan size={14} />
321
+
Blocked accounts ({blocks.length})
322
+
</h3>
323
+
{blocks.length === 0 ? (
324
+
<p className="text-sm text-surface-400 dark:text-surface-500 pl-6">
325
+
No blocked accounts
326
+
</p>
327
+
) : (
328
+
<div className="space-y-1.5">
329
+
{blocks.map((b) => (
330
+
<div
331
+
key={b.did}
332
+
className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl group hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
333
+
>
334
+
<Link
335
+
to={`/profile/${b.did}`}
336
+
className="flex items-center gap-3 min-w-0 flex-1"
337
+
>
338
+
<Avatar
339
+
did={b.did}
340
+
avatar={b.author?.avatar}
341
+
size="sm"
342
+
/>
343
+
<div className="min-w-0">
344
+
<p className="font-medium text-surface-900 dark:text-white text-sm truncate">
345
+
{b.author?.displayName ||
346
+
b.author?.handle ||
347
+
b.did}
348
+
</p>
349
+
{b.author?.handle && (
350
+
<p className="text-xs text-surface-400 dark:text-surface-500 truncate">
351
+
@{b.author.handle}
352
+
</p>
353
+
)}
354
+
</div>
355
+
</Link>
356
+
<button
357
+
onClick={async () => {
358
+
await unblockUser(b.did);
359
+
setBlocks((prev) =>
360
+
prev.filter((x) => x.did !== b.did),
361
+
);
362
+
}}
363
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
364
+
>
365
+
<ShieldOff size={12} />
366
+
Unblock
367
+
</button>
368
+
</div>
369
+
))}
370
+
</div>
371
+
)}
372
+
</div>
373
+
374
+
<div>
375
+
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2">
376
+
<VolumeX size={14} />
377
+
Muted accounts ({mutes.length})
378
+
</h3>
379
+
{mutes.length === 0 ? (
380
+
<p className="text-sm text-surface-400 dark:text-surface-500 pl-6">
381
+
No muted accounts
382
+
</p>
383
+
) : (
384
+
<div className="space-y-1.5">
385
+
{mutes.map((m) => (
386
+
<div
387
+
key={m.did}
388
+
className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl group hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
389
+
>
390
+
<Link
391
+
to={`/profile/${m.did}`}
392
+
className="flex items-center gap-3 min-w-0 flex-1"
393
+
>
394
+
<Avatar
395
+
did={m.did}
396
+
avatar={m.author?.avatar}
397
+
size="sm"
398
+
/>
399
+
<div className="min-w-0">
400
+
<p className="font-medium text-surface-900 dark:text-white text-sm truncate">
401
+
{m.author?.displayName ||
402
+
m.author?.handle ||
403
+
m.did}
404
+
</p>
405
+
{m.author?.handle && (
406
+
<p className="text-xs text-surface-400 dark:text-surface-500 truncate">
407
+
@{m.author.handle}
408
+
</p>
409
+
)}
410
+
</div>
411
+
</Link>
412
+
<button
413
+
onClick={async () => {
414
+
await unmuteUser(m.did);
415
+
setMutes((prev) =>
416
+
prev.filter((x) => x.did !== m.did),
417
+
);
418
+
}}
419
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
420
+
>
421
+
<Volume2 size={12} />
422
+
Unmute
423
+
</button>
424
+
</div>
425
+
))}
426
+
</div>
427
+
)}
428
+
</div>
429
+
</div>
430
+
)}
431
+
</section>
432
+
433
+
<section className="card p-5">
434
+
<h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">
435
+
Content Filtering
436
+
</h2>
437
+
<p className="text-sm text-surface-400 dark:text-surface-500 mb-5">
438
+
Subscribe to labelers and configure how labeled content appears
439
+
</p>
440
+
441
+
<div className="space-y-5">
442
+
<div>
443
+
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2">
444
+
<Shield size={14} />
445
+
Subscribed Labelers
446
+
</h3>
447
+
448
+
{preferences.subscribedLabelers.length === 0 ? (
449
+
<p className="text-sm text-surface-400 dark:text-surface-500 pl-6 mb-3">
450
+
No labelers subscribed
451
+
</p>
452
+
) : (
453
+
<div className="space-y-1.5 mb-3">
454
+
{preferences.subscribedLabelers.map((labeler) => (
455
+
<div
456
+
key={labeler.did}
457
+
className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl group hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
458
+
>
459
+
<div className="flex items-center gap-3 min-w-0 flex-1">
460
+
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
461
+
<Shield
462
+
size={14}
463
+
className="text-primary-600 dark:text-primary-400"
464
+
/>
465
+
</div>
466
+
<div className="min-w-0">
467
+
<p className="font-medium text-surface-900 dark:text-white text-sm truncate">
468
+
{labelerInfo?.did === labeler.did
469
+
? labelerInfo.name
470
+
: labeler.did}
471
+
</p>
472
+
<p className="text-xs text-surface-400 dark:text-surface-500 truncate font-mono">
473
+
{labeler.did}
474
+
</p>
475
+
</div>
476
+
</div>
477
+
<button
478
+
onClick={() => removeLabeler(labeler.did)}
479
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
480
+
>
481
+
<XCircle size={12} />
482
+
Remove
483
+
</button>
484
+
</div>
485
+
))}
486
+
</div>
487
+
)}
488
+
489
+
<form
490
+
onSubmit={async (e) => {
491
+
e.preventDefault();
492
+
if (!newLabelerDid.trim()) return;
493
+
setAddingLabeler(true);
494
+
await addLabeler(newLabelerDid.trim());
495
+
setNewLabelerDid("");
496
+
setAddingLabeler(false);
497
+
}}
498
+
className="flex gap-2"
499
+
>
500
+
<div className="flex-1">
501
+
<Input
502
+
value={newLabelerDid}
503
+
onChange={(e) => setNewLabelerDid(e.target.value)}
504
+
placeholder="did:plc:... (labeler DID)"
505
+
/>
506
+
</div>
507
+
<Button
508
+
type="submit"
509
+
disabled={!newLabelerDid.trim()}
510
+
loading={addingLabeler}
511
+
icon={<Plus size={16} />}
512
+
>
513
+
Add
514
+
</Button>
515
+
</form>
516
+
</div>
517
+
518
+
{preferences.subscribedLabelers.length > 0 && (
519
+
<div>
520
+
<h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2">
521
+
<Eye size={14} />
522
+
Label Visibility
523
+
</h3>
524
+
<p className="text-xs text-surface-400 dark:text-surface-500 mb-3 pl-6">
525
+
Choose how to handle each label type: <strong>Warn</strong>{" "}
526
+
shows a blur overlay, <strong>Hide</strong> removes content
527
+
entirely, <strong>Ignore</strong> shows content normally.
528
+
</p>
529
+
530
+
<div className="space-y-4">
531
+
{preferences.subscribedLabelers.map((labeler) => {
532
+
const labels: ContentLabelValue[] = [
533
+
"sexual",
534
+
"nudity",
535
+
"violence",
536
+
"gore",
537
+
"spam",
538
+
"misleading",
539
+
];
540
+
return (
541
+
<div
542
+
key={labeler.did}
543
+
className="bg-surface-50 dark:bg-surface-800 rounded-xl p-4"
544
+
>
545
+
<p className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 truncate">
546
+
{labelerInfo?.did === labeler.did
547
+
? labelerInfo.name
548
+
: labeler.did}
549
+
</p>
550
+
<div className="space-y-2">
551
+
{labels.map((label) => {
552
+
const current = getLabelVisibility(
553
+
labeler.did,
554
+
label,
555
+
);
556
+
const options: {
557
+
value: LabelVisibilityType;
558
+
label: string;
559
+
icon: typeof Eye;
560
+
}[] = [
561
+
{ value: "warn", label: "Warn", icon: EyeOff },
562
+
{ value: "hide", label: "Hide", icon: XCircle },
563
+
{ value: "ignore", label: "Ignore", icon: Eye },
564
+
];
565
+
return (
566
+
<div
567
+
key={label}
568
+
className="flex items-center justify-between py-1.5"
569
+
>
570
+
<span className="text-sm text-surface-600 dark:text-surface-400 capitalize">
571
+
{label}
572
+
</span>
573
+
<div className="flex gap-1">
574
+
{options.map((opt) => (
575
+
<button
576
+
key={opt.value}
577
+
onClick={() =>
578
+
setLabelVisibility(
579
+
labeler.did,
580
+
label,
581
+
opt.value,
582
+
)
583
+
}
584
+
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all flex items-center gap-1 ${
585
+
current === opt.value
586
+
? opt.value === "hide"
587
+
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
588
+
: opt.value === "warn"
589
+
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
590
+
: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
591
+
: "text-surface-400 dark:text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-700"
592
+
}`}
593
+
>
594
+
<opt.icon size={12} />
595
+
{opt.label}
596
+
</button>
597
+
))}
598
+
</div>
599
+
</div>
600
+
);
601
+
})}
602
+
</div>
603
+
</div>
604
+
);
605
+
})}
606
+
</div>
607
+
</div>
608
+
)}
609
+
</div>
610
</section>
611
612
<section className="card p-5">
+287
-18
web/src/views/profile/Profile.tsx
···
1
import React, { useEffect, useState } from "react";
2
-
import { getProfile, getFeed, getCollections } from "../../api/client";
0
0
0
0
0
0
0
0
3
import Card from "../../components/common/Card";
4
import RichText from "../../components/common/RichText";
0
0
0
5
import {
6
Edit2,
7
Github,
···
12
PenTool,
13
Bookmark,
14
Link2,
0
0
0
0
0
0
0
15
} from "lucide-react";
16
import { TangledIcon } from "../../components/common/Icons";
17
-
import type { UserProfile, AnnotationItem, Collection } from "../../types";
0
0
0
0
0
0
0
18
import { useStore } from "@nanostores/react";
19
import { $user } from "../../store/auth";
20
import EditProfileModal from "../../components/modals/EditProfileModal";
···
22
import CollectionIcon from "../../components/common/CollectionIcon";
23
import { $preferences, loadPreferences } from "../../store/preferences";
24
import { Link } from "react-router-dom";
0
25
import {
26
Avatar,
27
Tabs,
···
51
const isOwner = user?.did === did;
52
const [showEdit, setShowEdit] = useState(false);
53
const [externalLink, setExternalLink] = useState<string | null>(null);
0
0
0
0
0
0
0
0
0
54
55
const formatLinkText = (url: string) => {
56
try {
···
116
postsCount: bskyData?.postsCount || marginData?.postsCount,
117
};
118
0
0
0
0
119
setProfile(merged);
0
0
0
0
0
0
0
0
0
0
0
120
} catch (e) {
121
console.error("Profile load failed", e);
122
} finally {
···
218
? highlights
219
: bookmarks;
220
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
221
return (
222
<div className="max-w-2xl mx-auto animate-slide-up">
223
<div className="card p-5 mb-4">
224
<div className="flex items-start gap-4">
225
-
<Avatar
226
-
did={profile.did}
227
-
avatar={profile.avatar}
228
-
size="xl"
229
-
className="ring-4 ring-surface-100 dark:ring-surface-800"
230
-
/>
0
0
0
0
0
0
0
0
0
0
0
231
232
<div className="flex-1 min-w-0">
233
<div className="flex items-start justify-between gap-3">
···
239
@{profile.handle}
240
</p>
241
</div>
242
-
{isOwner && (
243
-
<Button
244
-
variant="secondary"
245
-
size="sm"
246
-
onClick={() => setShowEdit(true)}
247
-
icon={<Edit2 size={14} />}
248
-
>
249
-
<span className="hidden sm:inline">Edit</span>
250
-
</Button>
251
-
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
252
</div>
253
254
{profile.description && (
···
316
</div>
317
</div>
318
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
319
<Tabs
320
tabs={tabs}
321
activeTab={activeTab}
···
406
isOpen={!!externalLink}
407
onClose={() => setExternalLink(null)}
408
url={externalLink}
0
0
0
0
0
0
0
409
/>
410
</div>
411
);
···
1
import React, { useEffect, useState } from "react";
2
+
import {
3
+
getProfile,
4
+
getFeed,
5
+
getCollections,
6
+
blockUser,
7
+
unblockUser,
8
+
muteUser,
9
+
unmuteUser,
10
+
} from "../../api/client";
11
import Card from "../../components/common/Card";
12
import RichText from "../../components/common/RichText";
13
+
import MoreMenu from "../../components/common/MoreMenu";
14
+
import type { MoreMenuItem } from "../../components/common/MoreMenu";
15
+
import ReportModal from "../../components/modals/ReportModal";
16
import {
17
Edit2,
18
Github,
···
23
PenTool,
24
Bookmark,
25
Link2,
26
+
ShieldBan,
27
+
VolumeX,
28
+
Flag,
29
+
ShieldOff,
30
+
Volume2,
31
+
EyeOff,
32
+
Eye,
33
} from "lucide-react";
34
import { TangledIcon } from "../../components/common/Icons";
35
+
import type {
36
+
UserProfile,
37
+
AnnotationItem,
38
+
Collection,
39
+
ModerationRelationship,
40
+
ContentLabel,
41
+
LabelVisibility,
42
+
} from "../../types";
43
import { useStore } from "@nanostores/react";
44
import { $user } from "../../store/auth";
45
import EditProfileModal from "../../components/modals/EditProfileModal";
···
47
import CollectionIcon from "../../components/common/CollectionIcon";
48
import { $preferences, loadPreferences } from "../../store/preferences";
49
import { Link } from "react-router-dom";
50
+
import { clsx } from "clsx";
51
import {
52
Avatar,
53
Tabs,
···
77
const isOwner = user?.did === did;
78
const [showEdit, setShowEdit] = useState(false);
79
const [externalLink, setExternalLink] = useState<string | null>(null);
80
+
const [showReportModal, setShowReportModal] = useState(false);
81
+
const [modRelation, setModRelation] = useState<ModerationRelationship>({
82
+
blocking: false,
83
+
muting: false,
84
+
blockedBy: false,
85
+
});
86
+
const [accountLabels, setAccountLabels] = useState<ContentLabel[]>([]);
87
+
const [profileRevealed, setProfileRevealed] = useState(false);
88
+
const preferences = useStore($preferences);
89
90
const formatLinkText = (url: string) => {
91
try {
···
151
postsCount: bskyData?.postsCount || marginData?.postsCount,
152
};
153
154
+
if (marginData?.labels && Array.isArray(marginData.labels)) {
155
+
setAccountLabels(marginData.labels);
156
+
}
157
+
158
setProfile(merged);
159
+
160
+
if (user && user.did !== did) {
161
+
try {
162
+
const { getModerationRelationship } =
163
+
await import("../../api/client");
164
+
const rel = await getModerationRelationship(did);
165
+
setModRelation(rel);
166
+
} catch {
167
+
// ignore
168
+
}
169
+
}
170
} catch (e) {
171
console.error("Profile load failed", e);
172
} finally {
···
268
? highlights
269
: bookmarks;
270
271
+
const LABEL_DESCRIPTIONS: Record<string, string> = {
272
+
sexual: "Sexual Content",
273
+
nudity: "Nudity",
274
+
violence: "Violence",
275
+
gore: "Graphic Content",
276
+
spam: "Spam",
277
+
misleading: "Misleading",
278
+
};
279
+
280
+
const accountWarning = (() => {
281
+
if (!accountLabels.length) return null;
282
+
const priority = [
283
+
"gore",
284
+
"violence",
285
+
"nudity",
286
+
"sexual",
287
+
"misleading",
288
+
"spam",
289
+
];
290
+
for (const p of priority) {
291
+
const match = accountLabels.find((l) => l.val === p);
292
+
if (match) {
293
+
const pref = preferences.labelPreferences.find(
294
+
(lp) => lp.label === p && lp.labelerDid === match.src,
295
+
);
296
+
const visibility = pref?.visibility || "warn";
297
+
if (visibility === "ignore") continue;
298
+
return {
299
+
label: p,
300
+
description: LABEL_DESCRIPTIONS[p] || p,
301
+
visibility,
302
+
};
303
+
}
304
+
}
305
+
return null;
306
+
})();
307
+
308
+
const shouldBlurAvatar = accountWarning && !profileRevealed;
309
+
310
return (
311
<div className="max-w-2xl mx-auto animate-slide-up">
312
<div className="card p-5 mb-4">
313
<div className="flex items-start gap-4">
314
+
<div className="relative">
315
+
<div className="rounded-full overflow-hidden">
316
+
<div
317
+
className={clsx(
318
+
"transition-all",
319
+
shouldBlurAvatar && "blur-lg",
320
+
)}
321
+
>
322
+
<Avatar
323
+
did={profile.did}
324
+
avatar={profile.avatar}
325
+
size="xl"
326
+
className="ring-4 ring-surface-100 dark:ring-surface-800"
327
+
/>
328
+
</div>
329
+
</div>
330
+
</div>
331
332
<div className="flex-1 min-w-0">
333
<div className="flex items-start justify-between gap-3">
···
339
@{profile.handle}
340
</p>
341
</div>
342
+
<div className="flex items-center gap-2">
343
+
{isOwner && (
344
+
<Button
345
+
variant="secondary"
346
+
size="sm"
347
+
onClick={() => setShowEdit(true)}
348
+
icon={<Edit2 size={14} />}
349
+
>
350
+
<span className="hidden sm:inline">Edit</span>
351
+
</Button>
352
+
)}
353
+
{!isOwner && user && (
354
+
<MoreMenu
355
+
items={(() => {
356
+
const items: MoreMenuItem[] = [];
357
+
if (modRelation.blocking) {
358
+
items.push({
359
+
label: `Unblock @${profile.handle || "user"}`,
360
+
icon: <ShieldOff size={14} />,
361
+
onClick: async () => {
362
+
await unblockUser(did);
363
+
setModRelation((prev) => ({
364
+
...prev,
365
+
blocking: false,
366
+
}));
367
+
},
368
+
});
369
+
} else {
370
+
items.push({
371
+
label: `Block @${profile.handle || "user"}`,
372
+
icon: <ShieldBan size={14} />,
373
+
onClick: async () => {
374
+
await blockUser(did);
375
+
setModRelation((prev) => ({
376
+
...prev,
377
+
blocking: true,
378
+
}));
379
+
},
380
+
variant: "danger",
381
+
});
382
+
}
383
+
if (modRelation.muting) {
384
+
items.push({
385
+
label: `Unmute @${profile.handle || "user"}`,
386
+
icon: <Volume2 size={14} />,
387
+
onClick: async () => {
388
+
await unmuteUser(did);
389
+
setModRelation((prev) => ({
390
+
...prev,
391
+
muting: false,
392
+
}));
393
+
},
394
+
});
395
+
} else {
396
+
items.push({
397
+
label: `Mute @${profile.handle || "user"}`,
398
+
icon: <VolumeX size={14} />,
399
+
onClick: async () => {
400
+
await muteUser(did);
401
+
setModRelation((prev) => ({
402
+
...prev,
403
+
muting: true,
404
+
}));
405
+
},
406
+
});
407
+
}
408
+
items.push({
409
+
label: "Report",
410
+
icon: <Flag size={14} />,
411
+
onClick: () => setShowReportModal(true),
412
+
variant: "danger",
413
+
});
414
+
return items;
415
+
})()}
416
+
/>
417
+
)}
418
+
</div>
419
</div>
420
421
{profile.description && (
···
483
</div>
484
</div>
485
486
+
{accountWarning && (
487
+
<div className="card p-4 mb-4 border-amber-200 dark:border-amber-800/50 bg-amber-50/50 dark:bg-amber-900/10">
488
+
<div className="flex items-center gap-3">
489
+
<EyeOff size={18} className="text-amber-500 flex-shrink-0" />
490
+
<div className="flex-1">
491
+
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
492
+
Account labeled: {accountWarning.description}
493
+
</p>
494
+
<p className="text-xs text-amber-600/70 dark:text-amber-400/60 mt-0.5">
495
+
This label was applied by a moderation service you subscribe to.
496
+
</p>
497
+
</div>
498
+
{!profileRevealed ? (
499
+
<button
500
+
onClick={() => setProfileRevealed(true)}
501
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors"
502
+
>
503
+
<Eye size={12} />
504
+
Show
505
+
</button>
506
+
) : (
507
+
<button
508
+
onClick={() => setProfileRevealed(false)}
509
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors"
510
+
>
511
+
<EyeOff size={12} />
512
+
Hide
513
+
</button>
514
+
)}
515
+
</div>
516
+
</div>
517
+
)}
518
+
519
+
{modRelation.blocking && (
520
+
<div className="card p-4 mb-4 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-900/10">
521
+
<div className="flex items-center gap-3">
522
+
<ShieldBan size={18} className="text-red-500 flex-shrink-0" />
523
+
<div className="flex-1">
524
+
<p className="text-sm font-medium text-red-700 dark:text-red-400">
525
+
You have blocked @{profile.handle}
526
+
</p>
527
+
<p className="text-xs text-red-600/70 dark:text-red-400/60 mt-0.5">
528
+
Their content is hidden from your feeds.
529
+
</p>
530
+
</div>
531
+
<button
532
+
onClick={async () => {
533
+
await unblockUser(did);
534
+
setModRelation((prev) => ({ ...prev, blocking: false }));
535
+
}}
536
+
className="px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
537
+
>
538
+
Unblock
539
+
</button>
540
+
</div>
541
+
</div>
542
+
)}
543
+
544
+
{modRelation.muting && !modRelation.blocking && (
545
+
<div className="card p-4 mb-4 border-amber-200 dark:border-amber-800/50 bg-amber-50/50 dark:bg-amber-900/10">
546
+
<div className="flex items-center gap-3">
547
+
<VolumeX size={18} className="text-amber-500 flex-shrink-0" />
548
+
<div className="flex-1">
549
+
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
550
+
You have muted @{profile.handle}
551
+
</p>
552
+
<p className="text-xs text-amber-600/70 dark:text-amber-400/60 mt-0.5">
553
+
Their content is hidden from your feeds.
554
+
</p>
555
+
</div>
556
+
<button
557
+
onClick={async () => {
558
+
await unmuteUser(did);
559
+
setModRelation((prev) => ({ ...prev, muting: false }));
560
+
}}
561
+
className="px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors"
562
+
>
563
+
Unmute
564
+
</button>
565
+
</div>
566
+
</div>
567
+
)}
568
+
569
+
{modRelation.blockedBy && !modRelation.blocking && (
570
+
<div className="card p-4 mb-4 border-surface-200 dark:border-surface-700">
571
+
<div className="flex items-center gap-3">
572
+
<ShieldBan size={18} className="text-surface-400 flex-shrink-0" />
573
+
<p className="text-sm text-surface-500 dark:text-surface-400">
574
+
@{profile.handle} has blocked you. You cannot interact with their
575
+
content.
576
+
</p>
577
+
</div>
578
+
</div>
579
+
)}
580
+
581
<Tabs
582
tabs={tabs}
583
activeTab={activeTab}
···
668
isOpen={!!externalLink}
669
onClose={() => setExternalLink(null)}
670
url={externalLink}
671
+
/>
672
+
673
+
<ReportModal
674
+
isOpen={showReportModal}
675
+
onClose={() => setShowReportModal(false)}
676
+
subjectDid={did}
677
+
subjectHandle={profile?.handle}
678
/>
679
</div>
680
);