(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

init moderation

scanash00 856910c9 eaaa8aac

+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 );