Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

init moderation

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