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