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

better notifications and logger

+437 -179
+11 -11
backend/cmd/server/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 5 "net/http" 7 6 "os" 8 7 "os/signal" ··· 17 16 "margin.at/internal/api" 18 17 "margin.at/internal/db" 19 18 "margin.at/internal/firehose" 19 + "margin.at/internal/logger" 20 20 internalMiddleware "margin.at/internal/middleware" 21 21 "margin.at/internal/oauth" 22 22 "margin.at/internal/sync" ··· 27 27 28 28 database, err := db.New(getEnv("DATABASE_URL", "margin.db")) 29 29 if err != nil { 30 - log.Fatalf("Failed to connect to database: %v", err) 30 + logger.Fatal("Failed to connect to database: %v", err) 31 31 } 32 32 defer database.Close() 33 33 34 34 if err := database.Migrate(); err != nil { 35 - log.Fatalf("Failed to run migrations: %v", err) 35 + logger.Fatal("Failed to run migrations: %v", err) 36 36 } 37 37 38 38 syncSvc := sync.NewService(database) 39 39 40 40 oauthHandler, err := oauth.NewHandler(database, syncSvc) 41 41 if err != nil { 42 - log.Fatalf("Failed to initialize OAuth: %v", err) 42 + logger.Fatal("Failed to initialize OAuth: %v", err) 43 43 } 44 44 45 45 ingester := firehose.NewIngester(database, syncSvc) 46 46 firehose.RelayURL = getEnv("BLOCK_RELAY_URL", "wss://jetstream2.us-east.bsky.network/subscribe") 47 - log.Printf("Firehose URL: %s", firehose.RelayURL) 47 + logger.Info("Firehose URL: %s", firehose.RelayURL) 48 48 49 49 go func() { 50 50 if err := ingester.Start(context.Background()); err != nil { 51 - log.Printf("Firehose ingester error: %v", err) 51 + logger.Error("Firehose ingester error: %v", err) 52 52 } 53 53 }() 54 54 ··· 114 114 } 115 115 116 116 go func() { 117 - log.Printf("Margin API server running on :%s", port) 117 + logger.Info("Margin API server running on :%s", port) 118 118 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 119 - log.Fatalf("Server error: %v", err) 119 + logger.Fatal("Server error: %v", err) 120 120 } 121 121 }() 122 122 ··· 124 124 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 125 125 <-quit 126 126 127 - log.Println("Shutting down server...") 127 + logger.Infoln("Shutting down server...") 128 128 ingester.Stop() 129 129 130 130 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 131 131 defer cancel() 132 132 133 133 if err := server.Shutdown(ctx); err != nil { 134 - log.Fatalf("Server forced to shutdown: %v", err) 134 + logger.Fatal("Server forced to shutdown: %v", err) 135 135 } 136 136 137 - log.Println("Server exited") 137 + logger.Infoln("Server exited") 138 138 } 139 139 140 140 func getEnv(key, fallback string) string {
+33 -18
backend/internal/api/annotations.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 - "log" 7 6 "net/http" 8 7 "regexp" 9 8 "strings" 10 9 "time" 11 10 12 11 "margin.at/internal/db" 12 + "margin.at/internal/logger" 13 13 "margin.at/internal/xrpc" 14 14 ) 15 15 ··· 220 220 } 221 221 222 222 if err := s.db.CreateAnnotation(annotation); err != nil { 223 - log.Printf("Warning: failed to index annotation in local DB: %v", err) 223 + logger.Error("Warning: failed to index annotation in local DB: %v", err) 224 224 } 225 225 226 226 for _, label := range validLabels { 227 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) 228 + logger.Error("Warning: failed to create self-label %s: %v", label, err) 229 229 } 230 230 } 231 231 ··· 271 271 return client.DeleteRecord(r.Context(), did, collection, rkey) 272 272 }) 273 273 if pdsErr != nil { 274 - log.Printf("PDS delete failed (will still clean local DB): %v", pdsErr) 274 + logger.Error("PDS delete failed (will still clean local DB): %v", pdsErr) 275 275 } 276 276 277 277 // Always clean up local DB regardless of PDS result ··· 338 338 339 339 if annotation.BodyValue != nil { 340 340 previousContent := *annotation.BodyValue 341 - log.Printf("[DEBUG] Saving edit history for %s. Previous content: %s", uri, previousContent) 341 + logger.Info("[DEBUG] Saving edit history for %s. Previous content: %s", uri, previousContent) 342 342 if err := s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID); err != nil { 343 - log.Printf("Failed to save edit history for %s: %v", uri, err) 343 + logger.Error("Failed to save edit history for %s: %v", uri, err) 344 344 } else { 345 - log.Printf("[DEBUG] Successfully saved edit history for %s", uri) 345 + logger.Info("[DEBUG] Successfully saved edit history for %s", uri) 346 346 } 347 347 } else { 348 - log.Printf("[DEBUG] Annotation BodyValue is nil for %s", uri) 348 + logger.Info("[DEBUG] Annotation BodyValue is nil for %s", uri) 349 349 } 350 350 351 351 var result *xrpc.PutRecordOutput ··· 386 386 var updateErr error 387 387 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 388 388 if updateErr != nil { 389 - log.Printf("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr) 389 + logger.Error("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr) 390 390 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 391 391 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 392 392 } ··· 394 394 }) 395 395 396 396 if err != nil { 397 - log.Printf("[UpdateAnnotation] Failed: %v", err) 397 + logger.Error("[UpdateAnnotation] Failed: %v", err) 398 398 HandleAPIError(w, r, err, "Failed to update record: ", http.StatusInternalServerError) 399 399 return 400 400 } ··· 409 409 } 410 410 } 411 411 if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { 412 - log.Printf("Warning: failed to sync self-labels: %v", err) 412 + logger.Error("Warning: failed to sync self-labels: %v", err) 413 413 } 414 414 415 415 w.Header().Set("Content-Type", "application/json") ··· 610 610 }) 611 611 } 612 612 613 + if req.RootURI != req.ParentURI { 614 + if rootAuthorDID, err := s.db.GetAuthorByURI(req.RootURI); err == nil && rootAuthorDID != session.DID { 615 + parentAuthorDID, _ := s.db.GetAuthorByURI(req.ParentURI) 616 + if rootAuthorDID != parentAuthorDID { 617 + s.db.CreateNotification(&db.Notification{ 618 + RecipientDID: rootAuthorDID, 619 + ActorDID: session.DID, 620 + Type: "reply", 621 + SubjectURI: result.URI, 622 + CreatedAt: time.Now(), 623 + }) 624 + } 625 + } 626 + } 627 + 613 628 w.Header().Set("Content-Type", "application/json") 614 629 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI}) 615 630 } ··· 758 773 759 774 for _, label := range validLabels { 760 775 if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { 761 - log.Printf("Warning: failed to create self-label %s: %v", label, err) 776 + logger.Error("Warning: failed to create self-label %s: %v", label, err) 762 777 } 763 778 } 764 779 ··· 871 886 return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 872 887 }) 873 888 if pdsErr != nil { 874 - log.Printf("PDS delete highlight failed (will still clean local DB): %v", pdsErr) 889 + logger.Error("PDS delete highlight failed (will still clean local DB): %v", pdsErr) 875 890 } 876 891 877 892 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey ··· 898 913 return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 899 914 }) 900 915 if pdsErr != nil { 901 - log.Printf("PDS delete bookmark failed (will still clean local DB): %v", pdsErr) 916 + logger.Error("PDS delete bookmark failed (will still clean local DB): %v", pdsErr) 902 917 } 903 918 904 919 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey ··· 978 993 var updateErr error 979 994 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 980 995 if updateErr != nil { 981 - log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr) 996 + logger.Error("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr) 982 997 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 983 998 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 984 999 } ··· 1005 1020 } 1006 1021 } 1007 1022 if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { 1008 - log.Printf("Warning: failed to sync self-labels: %v", err) 1023 + logger.Error("Warning: failed to sync self-labels: %v", err) 1009 1024 } 1010 1025 1011 1026 w.Header().Set("Content-Type", "application/json") ··· 1086 1101 var updateErr error 1087 1102 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 1088 1103 if updateErr != nil { 1089 - log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr) 1104 + logger.Error("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr) 1090 1105 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 1091 1106 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 1092 1107 } ··· 1113 1128 } 1114 1129 } 1115 1130 if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { 1116 - log.Printf("Warning: failed to sync self-labels: %v", err) 1131 + logger.Error("Warning: failed to sync self-labels: %v", err) 1117 1132 } 1118 1133 1119 1134 w.Header().Set("Content-Type", "application/json")
+3 -3
backend/internal/api/apikey.go
··· 8 8 "encoding/json" 9 9 "encoding/pem" 10 10 "fmt" 11 - "log" 12 11 "net/http" 13 12 "strings" 14 13 "time" ··· 16 15 "github.com/go-chi/chi/v5" 17 16 18 17 "margin.at/internal/db" 18 + "margin.at/internal/logger" 19 19 "margin.at/internal/xrpc" 20 20 ) 21 21 ··· 73 73 return createErr 74 74 }) 75 75 if err != nil { 76 - log.Printf("[ERROR] Failed to create API key record on PDS: %v", err) 76 + logger.Error("[ERROR] Failed to create API key record on PDS: %v", err) 77 77 http.Error(w, "Failed to create key record: "+err.Error(), http.StatusInternalServerError) 78 78 return 79 79 } ··· 92 92 } 93 93 94 94 if err := h.db.CreateAPIKey(apiKey); err != nil { 95 - log.Printf("[ERROR] Failed to insert API key into DB: %v", err) 95 + logger.Error("[ERROR] Failed to insert API key into DB: %v", err) 96 96 http.Error(w, "Failed to create key", http.StatusInternalServerError) 97 97 return 98 98 }
+5 -5
backend/internal/api/collections.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "log" 8 7 "net/http" 9 8 "net/url" 10 9 "strings" ··· 13 12 "github.com/go-chi/chi/v5" 14 13 15 14 "margin.at/internal/db" 15 + "margin.at/internal/logger" 16 16 "margin.at/internal/xrpc" 17 17 ) 18 18 ··· 150 150 IndexedAt: time.Now(), 151 151 } 152 152 if err := s.db.AddToCollection(item); err != nil { 153 - log.Printf("Failed to add to collection in DB: %v", err) 153 + logger.Error("Failed to add to collection in DB: %v", err) 154 154 } 155 155 156 156 w.Header().Set("Content-Type", "application/json") ··· 174 174 return client.DeleteRecordByURI(r.Context(), itemURI) 175 175 }) 176 176 if err != nil { 177 - log.Printf("Warning: PDS delete failed for %s: %v", itemURI, err) 177 + logger.Error("Warning: PDS delete failed for %s: %v", itemURI, err) 178 178 } 179 179 180 180 s.db.RemoveFromCollection(itemURI) ··· 316 316 317 317 enrichedItems, err := hydrateCollectionItems(s.db, items, viewerDID) 318 318 if err != nil { 319 - log.Printf("Hydration error: %v", err) 319 + logger.Error("Hydration error: %v", err) 320 320 enrichedItems = []APICollectionItem{} 321 321 } 322 322 ··· 374 374 var updateErr error 375 375 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) 376 376 if updateErr != nil { 377 - log.Printf("DEBUG PutRecord failed: %v. Retrying with delete-then-create workaround for buggy PDS.", updateErr) 377 + logger.Error("DEBUG PutRecord failed: %v. Retrying with delete-then-create workaround for buggy PDS.", updateErr) 378 378 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) 379 379 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) 380 380 }
+10 -10
backend/internal/api/handler.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "io" 8 - "log" 9 8 "net/http" 10 9 "net/url" 11 10 "sort" ··· 16 15 "github.com/go-chi/chi/v5" 17 16 18 17 "margin.at/internal/db" 18 + "margin.at/internal/logger" 19 19 internal_sync "margin.at/internal/sync" 20 20 "margin.at/internal/xrpc" 21 21 ) ··· 334 334 collectionItems, err = h.db.GetRecentCollectionItems(fetchLimit, 0) 335 335 } 336 336 if err != nil { 337 - log.Printf("Error fetching collection items: %v\n", err) 337 + logger.Error("Error fetching collection items: %v", err) 338 338 } 339 339 } 340 340 } ··· 449 449 sortFeed(feed) 450 450 } 451 451 452 - log.Printf("[DEBUG] FeedType: %s, Total Items before slice: %d", feedType, len(feed)) 452 + logger.Info("[DEBUG] FeedType: %s, Total Items before slice: %d", feedType, len(feed)) 453 453 if len(feed) > 0 { 454 454 first := feed[0] 455 455 switch v := first.(type) { 456 456 case APIAnnotation: 457 - log.Printf("[DEBUG] First Item (Annotation): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) 457 + logger.Info("[DEBUG] First Item (Annotation): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) 458 458 case APIHighlight: 459 - log.Printf("[DEBUG] First Item (Highlight): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) 459 + logger.Info("[DEBUG] First Item (Highlight): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) 460 460 } 461 461 } 462 462 ··· 783 783 784 784 annotations, highlights, bookmarks, err := ConstellationClient.GetAllItemsForURL(ctx, source) 785 785 if err != nil { 786 - log.Printf("Constellation discover error, falling back to local: %v", err) 786 + logger.Error("Constellation discover error, falling back to local: %v", err) 787 787 h.GetByTarget(w, r) 788 788 return 789 789 } ··· 949 949 if offset == 0 && viewerDID != "" && did == viewerDID { 950 950 go func() { 951 951 if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit); err != nil { 952 - log.Printf("Background sync error (annotations): %v", err) 952 + logger.Error("Background sync error (annotations): %v", err) 953 953 } 954 954 }() 955 955 } ··· 989 989 if offset == 0 && viewerDID != "" && did == viewerDID { 990 990 go func() { 991 991 if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit); err != nil { 992 - log.Printf("Background sync error (highlights): %v", err) 992 + logger.Error("Background sync error (highlights): %v", err) 993 993 } 994 994 }() 995 995 } ··· 1029 1029 if offset == 0 && viewerDID != "" && did == viewerDID { 1030 1030 go func() { 1031 1031 if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit); err != nil { 1032 - log.Printf("Background sync error (bookmarks): %v", err) 1032 + logger.Error("Background sync error (bookmarks): %v", err) 1033 1033 } 1034 1034 }() 1035 1035 } ··· 1332 1332 1333 1333 enriched, err := hydrateNotifications(h.db, notifications) 1334 1334 if err != nil { 1335 - log.Printf("Failed to hydrate notifications: %v\n", err) 1335 + logger.Error("Failed to hydrate notifications: %v", err) 1336 1336 } 1337 1337 1338 1338 w.Header().Set("Content-Type", "application/json")
+34 -5
backend/internal/api/hydration.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "log" 8 7 "net/http" 9 8 "net/url" 10 9 "strings" ··· 14 13 "margin.at/internal/config" 15 14 "margin.at/internal/constellation" 16 15 "margin.at/internal/db" 16 + "margin.at/internal/logger" 17 17 ) 18 18 19 19 var ( ··· 22 22 ) 23 23 24 24 func init() { 25 - log.Printf("Constellation client initialized: %s", constellation.DefaultBaseURL) 25 + logger.Info("Constellation client initialized: %s", constellation.DefaultBaseURL) 26 26 } 27 27 28 28 type Author struct { ··· 188 188 if ConstellationClient != nil && len(uris) <= 5 { 189 189 constellationCounts, err := ConstellationClient.GetCountsBatch(ctx, uris) 190 190 if err != nil { 191 - log.Printf("Constellation fetch error (non-fatal): %v", err) 191 + logger.Error("Constellation fetch error (non-fatal): %v", err) 192 192 return 193 193 } 194 194 ··· 581 581 582 582 resp, err := http.Get(config.Get().BskyGetProfilesURL() + "?" + q.Encode()) 583 583 if err != nil { 584 - log.Printf("Hydration fetch error: %v\n", err) 584 + logger.Error("Hydration fetch error: %v", err) 585 585 return nil, err 586 586 } 587 587 defer resp.Body.Close() 588 588 589 589 if resp.StatusCode != 200 { 590 - log.Printf("Hydration fetch status error: %d\n", resp.StatusCode) 590 + logger.Error("Hydration fetch status error: %d", resp.StatusCode) 591 591 return nil, fmt.Errorf("failed to fetch profiles: %d", resp.StatusCode) 592 592 } 593 593 ··· 770 770 profiles := fetchProfilesForDIDs(database, dids) 771 771 772 772 replyURIs := make([]string, 0) 773 + contentURIs := make([]string, 0) 773 774 for _, n := range notifications { 774 775 if n.Type == "reply" { 775 776 replyURIs = append(replyURIs, n.SubjectURI) 777 + } else if n.Type != "follow" && n.SubjectURI != "" { 778 + contentURIs = append(contentURIs, n.SubjectURI) 776 779 } 777 780 } 778 781 ··· 787 790 } 788 791 } 789 792 793 + contentMap := make(map[string]interface{}) 794 + if len(contentURIs) > 0 { 795 + if annotations, err := database.GetAnnotationsByURIs(contentURIs); err == nil && len(annotations) > 0 { 796 + hydratedAnnotations, _ := hydrateAnnotations(database, annotations, "") 797 + for _, a := range hydratedAnnotations { 798 + contentMap[a.ID] = a 799 + } 800 + } 801 + if highlights, err := database.GetHighlightsByURIs(contentURIs); err == nil && len(highlights) > 0 { 802 + hydratedHighlights, _ := hydrateHighlights(database, highlights, "") 803 + for _, h := range hydratedHighlights { 804 + contentMap[h.ID] = h 805 + } 806 + } 807 + if bookmarks, err := database.GetBookmarksByURIs(contentURIs); err == nil && len(bookmarks) > 0 { 808 + hydratedBookmarks, _ := hydrateBookmarks(database, bookmarks, "") 809 + for _, b := range hydratedBookmarks { 810 + contentMap[b.ID] = b 811 + } 812 + } 813 + } 814 + 790 815 result := make([]APINotification, len(notifications)) 791 816 for i, n := range notifications { 792 817 var subject interface{} 793 818 if n.Type == "reply" { 794 819 if val, ok := replyMap[n.SubjectURI]; ok { 820 + subject = val 821 + } 822 + } else if n.SubjectURI != "" { 823 + if val, ok := contentMap[n.SubjectURI]; ok { 795 824 subject = val 796 825 } 797 826 }
+9 -9
backend/internal/api/moderation.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 - "log" 6 5 "net/http" 7 6 "strconv" 8 7 9 8 "margin.at/internal/config" 10 9 "margin.at/internal/db" 10 + "margin.at/internal/logger" 11 11 ) 12 12 13 13 type ModerationHandler struct { ··· 40 40 } 41 41 42 42 if err := m.db.CreateBlock(session.DID, req.DID); err != nil { 43 - log.Printf("Failed to create block: %v", err) 43 + logger.Error("Failed to create block: %v", err) 44 44 http.Error(w, "Failed to block user", http.StatusInternalServerError) 45 45 return 46 46 } ··· 63 63 } 64 64 65 65 if err := m.db.DeleteBlock(session.DID, did); err != nil { 66 - log.Printf("Failed to delete block: %v", err) 66 + logger.Error("Failed to delete block: %v", err) 67 67 http.Error(w, "Failed to unblock user", http.StatusInternalServerError) 68 68 return 69 69 } ··· 131 131 } 132 132 133 133 if err := m.db.CreateMute(session.DID, req.DID); err != nil { 134 - log.Printf("Failed to create mute: %v", err) 134 + logger.Error("Failed to create mute: %v", err) 135 135 http.Error(w, "Failed to mute user", http.StatusInternalServerError) 136 136 return 137 137 } ··· 154 154 } 155 155 156 156 if err := m.db.DeleteMute(session.DID, did); err != nil { 157 - log.Printf("Failed to delete mute: %v", err) 157 + logger.Error("Failed to delete mute: %v", err) 158 158 http.Error(w, "Failed to unmute user", http.StatusInternalServerError) 159 159 return 160 160 } ··· 263 263 264 264 id, err := m.db.CreateReport(session.DID, req.SubjectDID, req.SubjectURI, req.ReasonType, req.ReasonText) 265 265 if err != nil { 266 - log.Printf("Failed to create report: %v", err) 266 + logger.Error("Failed to create report: %v", err) 267 267 http.Error(w, "Failed to submit report", http.StatusInternalServerError) 268 268 return 269 269 } ··· 389 389 } 390 390 391 391 if err := m.db.CreateModerationAction(req.ReportID, session.DID, req.Action, req.Comment); err != nil { 392 - log.Printf("Failed to create moderation action: %v", err) 392 + logger.Error("Failed to create moderation action: %v", err) 393 393 http.Error(w, "Failed to take action", http.StatusInternalServerError) 394 394 return 395 395 } ··· 410 410 } 411 411 412 412 if err := m.db.ResolveReport(req.ReportID, session.DID, resolveStatus); err != nil { 413 - log.Printf("Failed to resolve report: %v", err) 413 + logger.Error("Failed to resolve report: %v", err) 414 414 } 415 415 416 416 w.Header().Set("Content-Type", "application/json") ··· 531 531 } 532 532 533 533 if err := m.db.CreateContentLabel(labelerDID, targetURI, req.Val, session.DID); err != nil { 534 - log.Printf("Failed to create content label: %v", err) 534 + logger.Error("Failed to create content label: %v", err) 535 535 http.Error(w, "Failed to create label", http.StatusInternalServerError) 536 536 return 537 537 }
+3 -3
backend/internal/api/semble_fetch.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "log" 8 7 "net/http" 9 8 "strings" 10 9 "sync" 11 10 "time" 12 11 13 12 "margin.at/internal/db" 13 + "margin.at/internal/logger" 14 14 "margin.at/internal/xrpc" 15 15 ) 16 16 ··· 56 56 return 57 57 } 58 58 59 - log.Printf("Active Cache: Fetching %d missing Semble cards...", len(missing)) 59 + logger.Info("Active Cache: Fetching %d missing Semble cards...", len(missing)) 60 60 fetchAndIndexSembleCards(ctx, database, missing) 61 61 } 62 62 ··· 84 84 85 85 if err := fetchSembleCard(ctx, database, u); err != nil { 86 86 if ctx.Err() == nil { 87 - log.Printf("Failed to lazy fetch card %s: %v", u, err) 87 + logger.Error("Failed to lazy fetch card %s: %v", u, err) 88 88 } 89 89 } 90 90 }(uri)
+4 -4
backend/internal/api/token_refresh.go
··· 8 8 "encoding/pem" 9 9 "errors" 10 10 "fmt" 11 - "log" 12 11 "net/http" 13 12 "os" 14 13 "time" 15 14 16 15 "margin.at/internal/db" 16 + "margin.at/internal/logger" 17 17 "margin.at/internal/oauth" 18 18 "margin.at/internal/xrpc" 19 19 ) ··· 156 156 return nil, fmt.Errorf("failed to save refreshed session: %w", err) 157 157 } 158 158 159 - log.Printf("Successfully refreshed token for user %s", session.Handle) 159 + logger.Info("Successfully refreshed token for user %s", session.Handle) 160 160 161 161 return &SessionData{ 162 162 ID: session.ID, ··· 194 194 return err 195 195 } 196 196 197 - log.Printf("Token expired for user %s, attempting refresh...", session.Handle) 197 + logger.Info("Token expired for user %s, attempting refresh...", session.Handle) 198 198 199 199 newSession, refreshErr := tr.RefreshSessionToken(r, session) 200 200 if refreshErr != nil { 201 - log.Printf("Token refresh failed for user %s, invalidating session: %v", session.Handle, refreshErr) 201 + logger.Error("Token refresh failed for user %s, invalidating session: %v", session.Handle, refreshErr) 202 202 tr.db.DeleteSession(session.ID) 203 203 return fmt.Errorf("%w: %v", ErrSessionInvalid, refreshErr) 204 204 }
+34 -34
backend/internal/firehose/ingester.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "log" 8 7 "strings" 9 8 "sync" 10 9 "time" ··· 12 11 "github.com/gorilla/websocket" 13 12 "margin.at/internal/crypto" 14 13 "margin.at/internal/db" 14 + "margin.at/internal/logger" 15 15 internal_sync "margin.at/internal/sync" 16 16 "margin.at/internal/xrpc" 17 17 ) ··· 104 104 default: 105 105 if err := i.subscribe(ctx); err != nil { 106 106 consecutiveFailures++ 107 - log.Printf("Jetstream error (relay %d): %v, reconnecting in 5s...", i.currentRelayIdx, err) 107 + logger.Error("Jetstream error (relay %d): %v, reconnecting in 5s...", i.currentRelayIdx, err) 108 108 109 109 if consecutiveFailures >= maxFailuresBeforeSwitch { 110 110 i.currentRelayIdx = (i.currentRelayIdx + 1) % len(RelayURLs) 111 - log.Printf("Switching to relay %d: %s", i.currentRelayIdx, RelayURLs[i.currentRelayIdx]) 111 + logger.Info("Switching to relay %d: %s", i.currentRelayIdx, RelayURLs[i.currentRelayIdx]) 112 112 consecutiveFailures = 0 113 113 } 114 114 ··· 153 153 url = fmt.Sprintf("%s&cursor=%d", url, cursor) 154 154 } 155 155 156 - log.Printf("Connecting to Jetstream: %s", url) 156 + logger.Info("Connecting to Jetstream: %s", url) 157 157 158 158 conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 159 159 if err != nil { ··· 161 161 } 162 162 defer conn.Close() 163 163 164 - log.Printf("Connected to Jetstream") 164 + logger.Info("Connected to Jetstream") 165 165 166 166 for { 167 167 select { ··· 185 185 186 186 if event.Time > 0 { 187 187 if err := i.db.SetCursor("firehose_cursor", event.Time); err != nil { 188 - log.Printf("Failed to save cursor: %v", err) 188 + logger.Error("Failed to save cursor: %v", err) 189 189 } 190 190 } 191 191 } ··· 201 201 if len(commit.Record) > 0 { 202 202 if CIDVerificationEnabled && commit.Cid != "" { 203 203 if err := crypto.VerifyRecordCID(commit.Record, commit.Cid, uri); err != nil { 204 - log.Printf("CID verification failed for %s: %v (skipping)", uri, err) 204 + logger.Error("CID verification failed for %s: %v (skipping)", uri, err) 205 205 return 206 206 } 207 207 } ··· 254 254 }) 255 255 256 256 if err == nil { 257 - log.Printf("Auto-synced repo for active user: %s", did) 257 + logger.Info("Auto-synced repo for active user: %s", did) 258 258 } 259 259 } 260 260 ··· 294 294 func (i *Ingester) getLastCursor() int64 { 295 295 cursor, err := i.db.GetCursor("firehose_cursor") 296 296 if err != nil { 297 - log.Printf("Failed to get last cursor from DB: %v", err) 297 + logger.Error("Failed to get last cursor from DB: %v", err) 298 298 return 0 299 299 } 300 300 return cursor ··· 410 410 } 411 411 412 412 if err := i.db.CreateAnnotation(annotation); err != nil { 413 - log.Printf("Failed to index annotation: %v", err) 413 + logger.Error("Failed to index annotation: %v", err) 414 414 } else { 415 - log.Printf("Indexed annotation from %s on %s", event.Repo, targetSource) 415 + logger.Info("Indexed annotation from %s on %s", event.Repo, targetSource) 416 416 } 417 417 } 418 418 ··· 542 542 } 543 543 544 544 if err := i.db.CreateHighlight(highlight); err != nil { 545 - log.Printf("Failed to index highlight: %v", err) 545 + logger.Error("Failed to index highlight: %v", err) 546 546 } else { 547 - log.Printf("Indexed highlight from %s on %s", event.Repo, record.Target.Source) 547 + logger.Info("Indexed highlight from %s on %s", event.Repo, record.Target.Source) 548 548 } 549 549 } 550 550 ··· 600 600 } 601 601 602 602 if err := i.db.CreateBookmark(bookmark); err != nil { 603 - log.Printf("Failed to index bookmark: %v", err) 603 + logger.Error("Failed to index bookmark: %v", err) 604 604 } else { 605 - log.Printf("Indexed bookmark from %s: %s", event.Repo, record.Source) 605 + logger.Info("Indexed bookmark from %s: %s", event.Repo, record.Source) 606 606 } 607 607 } 608 608 ··· 644 644 } 645 645 646 646 if err := i.db.CreateCollection(collection); err != nil { 647 - log.Printf("Failed to index collection: %v", err) 647 + logger.Error("Failed to index collection: %v", err) 648 648 } else { 649 - log.Printf("Indexed collection from %s: %s", event.Repo, record.Name) 649 + logger.Info("Indexed collection from %s: %s", event.Repo, record.Name) 650 650 } 651 651 } 652 652 ··· 680 680 } 681 681 682 682 if err := i.db.AddToCollection(item); err != nil { 683 - log.Printf("Failed to index collection item: %v", err) 683 + logger.Error("Failed to index collection item: %v", err) 684 684 } else { 685 - log.Printf("Indexed collection item from %s", event.Repo) 685 + logger.Info("Indexed collection item from %s", event.Repo) 686 686 } 687 687 } 688 688 ··· 738 738 } 739 739 740 740 if err := i.db.UpsertProfile(profile); err != nil { 741 - log.Printf("Failed to index profile: %v", err) 741 + logger.Error("Failed to index profile: %v", err) 742 742 } else { 743 - log.Printf("Indexed profile from %s", event.Repo) 743 + logger.Info("Indexed profile from %s", event.Repo) 744 744 } 745 745 } 746 746 ··· 779 779 } 780 780 781 781 if err := i.db.CreateAPIKey(apiKey); err != nil { 782 - log.Printf("Failed to index API key: %v", err) 782 + logger.Error("Failed to index API key: %v", err) 783 783 } else { 784 - log.Printf("Indexed API key from %s: %s", event.Repo, record.Name) 784 + logger.Info("Indexed API key from %s: %s", event.Repo, record.Name) 785 785 } 786 786 } 787 787 ··· 846 846 } 847 847 848 848 if err := i.db.UpsertPreferences(prefs); err != nil { 849 - log.Printf("Failed to index preferences: %v", err) 849 + logger.Error("Failed to index preferences: %v", err) 850 850 } else { 851 - log.Printf("Indexed preferences from %s", event.Repo) 851 + logger.Info("Indexed preferences from %s", event.Repo) 852 852 } 853 853 } 854 854 ··· 915 915 IndexedAt: time.Now(), 916 916 } 917 917 if err := i.db.CreateAnnotation(annotation); err != nil { 918 - log.Printf("Failed to index Semble NOTE as annotation: %v", err) 918 + logger.Error("Failed to index Semble NOTE as annotation: %v", err) 919 919 } else { 920 920 if card.ParentCard != nil { 921 - log.Printf("Indexed Semble NOTE from %s on %s (Parent: %s)", event.Repo, targetSource, card.ParentCard.URI) 921 + logger.Info("Indexed Semble NOTE from %s on %s (Parent: %s)", event.Repo, targetSource, card.ParentCard.URI) 922 922 } else { 923 - log.Printf("Indexed Semble NOTE from %s on %s", event.Repo, targetSource) 923 + logger.Info("Indexed Semble NOTE from %s on %s", event.Repo, targetSource) 924 924 } 925 925 } 926 926 ··· 952 952 IndexedAt: time.Now(), 953 953 } 954 954 if err := i.db.CreateBookmark(bookmark); err != nil { 955 - log.Printf("Failed to index Semble URL as bookmark: %v", err) 955 + logger.Error("Failed to index Semble URL as bookmark: %v", err) 956 956 } else { 957 - log.Printf("Indexed Semble URL from %s: %s", event.Repo, source) 957 + logger.Info("Indexed Semble URL from %s: %s", event.Repo, source) 958 958 } 959 959 } 960 960 } ··· 989 989 } 990 990 991 991 if err := i.db.CreateCollection(collection); err != nil { 992 - log.Printf("Failed to index Semble collection: %v", err) 992 + logger.Error("Failed to index Semble collection: %v", err) 993 993 } else { 994 - log.Printf("Indexed Semble collection from %s: %s", event.Repo, record.Name) 994 + logger.Info("Indexed Semble collection from %s: %s", event.Repo, record.Name) 995 995 } 996 996 } 997 997 ··· 1018 1018 } 1019 1019 1020 1020 if err := i.db.AddToCollection(item); err != nil { 1021 - log.Printf("Failed to index Semble collection link: %v", err) 1021 + logger.Error("Failed to index Semble collection link: %v", err) 1022 1022 } else { 1023 - log.Printf("Indexed Semble collection link from %s", event.Repo) 1023 + logger.Info("Indexed Semble collection link from %s", event.Repo) 1024 1024 } 1025 1025 }
+27
backend/internal/logger/logger.go
··· 1 + package logger 2 + 3 + import ( 4 + "log" 5 + "os" 6 + ) 7 + 8 + var ( 9 + infoLog = log.New(os.Stdout, "", log.LstdFlags) 10 + errorLog = log.New(os.Stderr, "", log.LstdFlags) 11 + ) 12 + 13 + func Info(format string, args ...any) { 14 + infoLog.Printf(format, args...) 15 + } 16 + 17 + func Infoln(msg string) { 18 + infoLog.Println(msg) 19 + } 20 + 21 + func Error(format string, args ...any) { 22 + errorLog.Printf(format, args...) 23 + } 24 + 25 + func Fatal(format string, args ...any) { 26 + errorLog.Fatalf(format, args...) 27 + }
+2 -2
backend/internal/middleware/logger.go
··· 1 1 package middleware 2 2 3 3 import ( 4 - "log" 5 4 "net/http" 6 5 "net/url" 7 6 "strings" 8 7 "time" 9 8 10 9 "github.com/go-chi/chi/v5/middleware" 10 + "margin.at/internal/logger" 11 11 ) 12 12 13 13 func PrivacyLogger(next http.Handler) http.Handler { ··· 18 18 defer func() { 19 19 safeURL := redactURL(r.URL) 20 20 21 - log.Printf("[%d] %s %s %s", 21 + logger.Info("[%d] %s %s %s", 22 22 ww.Status(), 23 23 r.Method, 24 24 safeURL,
+14 -14
backend/internal/oauth/handler.go
··· 9 9 "encoding/json" 10 10 "encoding/pem" 11 11 "fmt" 12 - "log" 13 12 "net/http" 14 13 "net/url" 15 14 "os" ··· 18 17 "time" 19 18 20 19 "margin.at/internal/db" 20 + "margin.at/internal/logger" 21 21 internal_sync "margin.at/internal/sync" 22 22 "margin.at/internal/xrpc" 23 23 ) ··· 81 81 } 82 82 83 83 if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil { 84 - log.Printf("Warning: could not save key to %s: %v\n", keyPath, err) 84 + logger.Error("Warning: could not save key to %s: %v", keyPath, err) 85 85 } 86 86 87 87 return key, nil ··· 240 240 241 241 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 242 242 if err != nil { 243 - log.Printf("PAR request failed: %v", err) 243 + logger.Error("PAR request failed: %v", err) 244 244 w.Header().Set("Content-Type", "application/json") 245 245 w.WriteHeader(http.StatusInternalServerError) 246 246 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"}) ··· 300 300 301 301 meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL) 302 302 if err != nil { 303 - log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err) 303 + logger.Error("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err) 304 304 w.Header().Set("Content-Type", "application/json") 305 305 w.WriteHeader(http.StatusBadRequest) 306 306 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"}) ··· 321 321 parResp, state, dpopNonce, err := client.SendPARWithPrompt(meta, "", scope, dpopKey, pkceChallenge, "create") 322 322 if err != nil { 323 323 if strings.Contains(err.Error(), "prompt") || strings.Contains(err.Error(), "invalid_request") { 324 - log.Printf("prompt=create not supported, falling back to standard flow") 324 + logger.Info("prompt=create not supported, falling back to standard flow") 325 325 pkceVerifier, pkceChallenge = client.GeneratePKCE() 326 326 parResp, state, dpopNonce, err = client.SendPAR(meta, "", scope, dpopKey, pkceChallenge) 327 327 } 328 328 if err != nil { 329 - log.Printf("PAR request failed for signup: %v", err) 329 + logger.Error("PAR request failed for signup: %v", err) 330 330 w.Header().Set("Content-Type", "application/json") 331 331 w.WriteHeader(http.StatusInternalServerError) 332 332 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"}) ··· 368 368 369 369 if oauthErr := r.URL.Query().Get("error"); oauthErr != "" { 370 370 errDesc := r.URL.Query().Get("error_description") 371 - log.Printf("OAuth callback error: %s - %s", oauthErr, errDesc) 371 + logger.Error("OAuth callback error: %s - %s", oauthErr, errDesc) 372 372 373 373 if state := r.URL.Query().Get("state"); state != "" { 374 374 h.pendingMu.Lock() ··· 414 414 ctx := r.Context() 415 415 meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS) 416 416 if err != nil { 417 - log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err) 417 + logger.Error("Failed to get auth metadata in callback for %s: %v", pending.PDS, err) 418 418 http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) 419 419 return 420 420 } ··· 426 426 } 427 427 428 428 if pending.DID != "" && tokenResp.Sub != pending.DID { 429 - log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) 429 + logger.Error("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) 430 430 http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest) 431 431 return 432 432 } ··· 469 469 470 470 go h.cleanupOrphanedReplies(tokenResp.Sub, tokenResp.AccessToken, string(dpopKeyPEM), pending.PDS) 471 471 go func() { 472 - log.Printf("Starting background sync for %s...", tokenResp.Sub) 472 + logger.Info("Starting background sync for %s...", tokenResp.Sub) 473 473 _, err := h.syncService.PerformSync(context.Background(), tokenResp.Sub, func(ctx context.Context, did string) (*xrpc.Client, error) { 474 474 return xrpc.NewClient(pending.PDS, tokenResp.AccessToken, pending.DPoPKey), nil 475 475 }) 476 476 477 477 if err != nil { 478 - log.Printf("Background sync failed for %s: %v", tokenResp.Sub, err) 478 + logger.Error("Background sync failed for %s: %v", tokenResp.Sub, err) 479 479 } else { 480 - log.Printf("Background sync completed for %s", tokenResp.Sub) 480 + logger.Info("Background sync completed for %s", tokenResp.Sub) 481 481 } 482 482 }() 483 483 ··· 544 544 client := xrpc.NewClient(pds, accessToken, dpopKey) 545 545 err := client.DeleteRecord(context.Background(), did, collection, rkey) 546 546 if err != nil { 547 - log.Printf("Failed to delete orphaned reply from PDS: %v", err) 547 + logger.Error("Failed to delete orphaned reply from PDS: %v", err) 548 548 } else { 549 - log.Printf("Cleaned up orphaned reply %s/%s from PDS", collection, rkey) 549 + logger.Info("Cleaned up orphaned reply %s/%s from PDS", collection, rkey) 550 550 } 551 551 } 552 552
+2 -2
backend/internal/sync/service.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "io" 8 - "log" 9 8 "net/http" 10 9 "strings" 11 10 "time" 12 11 13 12 "margin.at/internal/crypto" 14 13 "margin.at/internal/db" 14 + "margin.at/internal/logger" 15 15 "margin.at/internal/xrpc" 16 16 ) 17 17 ··· 90 90 for _, rec := range output.Records { 91 91 if CIDVerificationEnabled && rec.CID != "" { 92 92 if err := crypto.VerifyRecordCID(rec.Value, rec.CID, rec.URI); err != nil { 93 - log.Printf("CID verification failed for %s: %v (skipping)", rec.URI, err) 93 + logger.Error("CID verification failed for %s: %v (skipping)", rec.URI, err) 94 94 continue 95 95 } 96 96 }
+2 -2
backend/internal/xrpc/utils.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "log" 8 7 "net/http" 9 8 "regexp" 10 9 "strings" 11 10 "time" 12 11 13 12 "margin.at/internal/config" 13 + "margin.at/internal/logger" 14 14 "margin.at/internal/slingshot" 15 15 ) 16 16 ··· 84 84 } 85 85 86 86 func init() { 87 - log.Printf("Slingshot client initialized: %s", slingshot.DefaultBaseURL) 87 + logger.Info("Slingshot client initialized: %s", slingshot.DefaultBaseURL) 88 88 } 89 89 90 90 func ResolveDIDToPDS(did string) (string, error) {
+244 -57
web/src/views/core/Notifications.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 + import { Link } from "react-router-dom"; 2 3 import { getNotifications, markNotificationsRead } from "../../api/client"; 3 4 import type { NotificationItem, AnnotationItem } from "../../types"; 4 - import { Heart, MessageCircle, Bell, PenTool } from "lucide-react"; 5 - import Card from "../../components/common/Card"; 5 + import { 6 + Heart, 7 + MessageCircle, 8 + Bell, 9 + PenTool, 10 + Bookmark, 11 + UserPlus, 12 + AtSign, 13 + ExternalLink, 14 + } from "lucide-react"; 6 15 import { formatDistanceToNow } from "date-fns"; 7 16 import { clsx } from "clsx"; 8 17 import { Avatar, EmptyState, Skeleton } from "../../components/ui"; 9 18 19 + function getContentType( 20 + uri: string, 21 + ): "annotation" | "highlight" | "bookmark" | "reply" | "unknown" { 22 + if (uri.includes("/at.margin.annotation/")) return "annotation"; 23 + if (uri.includes("/at.margin.highlight/")) return "highlight"; 24 + if (uri.includes("/at.margin.bookmark/")) return "bookmark"; 25 + if (uri.includes("/at.margin.reply/")) return "reply"; 26 + return "unknown"; 27 + } 28 + 29 + function getNotificationVerb( 30 + notifType: string, 31 + contentType: string, 32 + subject?: AnnotationItem, 33 + ): string { 34 + switch (notifType) { 35 + case "like": 36 + switch (contentType) { 37 + case "annotation": 38 + return "liked your annotation"; 39 + case "highlight": 40 + return "liked your highlight"; 41 + case "bookmark": 42 + return "liked your bookmark"; 43 + case "reply": 44 + return "liked your reply"; 45 + default: 46 + return "liked your post"; 47 + } 48 + case "reply": { 49 + const parentUri = (subject as any)?.inReplyTo as string | undefined; 50 + const parentIsReply = parentUri ? getContentType(parentUri) === "reply" : false; 51 + return parentIsReply ? "replied to your reply" : "replied to your annotation"; 52 + } 53 + case "mention": 54 + return "mentioned you in an annotation"; 55 + case "follow": 56 + return "followed you"; 57 + case "highlight": 58 + return "highlighted your page"; 59 + default: 60 + return notifType; 61 + } 62 + } 63 + 10 64 const NotificationIcon = ({ type }: { type: string }) => { 11 - const iconClass = "p-2 rounded-full"; 65 + const base = "p-2 rounded-full"; 12 66 switch (type) { 13 67 case "like": 14 68 return ( 15 - <div className={clsx(iconClass, "bg-red-100 dark:bg-red-900/30")}> 16 - <Heart size={16} className="text-red-500" /> 69 + <div className={clsx(base, "bg-red-100 dark:bg-red-900/30")}> 70 + <Heart size={15} className="text-red-500" /> 17 71 </div> 18 72 ); 19 73 case "reply": 20 74 return ( 21 - <div className={clsx(iconClass, "bg-blue-100 dark:bg-blue-900/30")}> 22 - <MessageCircle size={16} className="text-blue-500" /> 75 + <div className={clsx(base, "bg-blue-100 dark:bg-blue-900/30")}> 76 + <MessageCircle size={15} className="text-blue-500" /> 23 77 </div> 24 78 ); 25 79 case "highlight": 26 80 return ( 27 - <div className={clsx(iconClass, "bg-yellow-100 dark:bg-yellow-900/30")}> 28 - <PenTool size={16} className="text-yellow-600" /> 81 + <div className={clsx(base, "bg-yellow-100 dark:bg-yellow-900/30")}> 82 + <PenTool size={15} className="text-yellow-600" /> 83 + </div> 84 + ); 85 + case "bookmark": 86 + return ( 87 + <div className={clsx(base, "bg-green-100 dark:bg-green-900/30")}> 88 + <Bookmark size={15} className="text-green-600" /> 89 + </div> 90 + ); 91 + case "follow": 92 + return ( 93 + <div className={clsx(base, "bg-purple-100 dark:bg-purple-900/30")}> 94 + <UserPlus size={15} className="text-purple-500" /> 95 + </div> 96 + ); 97 + case "mention": 98 + return ( 99 + <div className={clsx(base, "bg-indigo-100 dark:bg-indigo-900/30")}> 100 + <AtSign size={15} className="text-indigo-500" /> 29 101 </div> 30 102 ); 31 103 default: 32 104 return ( 33 - <div className={clsx(iconClass, "bg-surface-100 dark:bg-surface-800")}> 34 - <Bell size={16} className="text-surface-500" /> 105 + <div className={clsx(base, "bg-surface-100 dark:bg-surface-800")}> 106 + <Bell size={15} className="text-surface-500" /> 35 107 </div> 36 108 ); 37 109 } 38 110 }; 39 111 112 + function SubjectPreview({ 113 + subject, 114 + subjectUri, 115 + }: { 116 + subject: AnnotationItem | unknown; 117 + subjectUri: string; 118 + }) { 119 + const item = subject as AnnotationItem | undefined; 120 + if (!item?.uri && !subjectUri) return null; 121 + 122 + const contentType = getContentType(subjectUri); 123 + const href = `/annotation/${encodeURIComponent(subjectUri)}`; 124 + 125 + let preview: React.ReactNode = null; 126 + 127 + if (contentType === "annotation") { 128 + const quote = item?.target?.selector?.exact; 129 + const body = item?.text || item?.body?.value; 130 + preview = ( 131 + <> 132 + {quote && ( 133 + <p className="text-surface-500 dark:text-surface-400 text-xs italic line-clamp-2 mb-1"> 134 + &ldquo;{quote}&rdquo; 135 + </p> 136 + )} 137 + {body && ( 138 + <p className="text-surface-700 dark:text-surface-300 text-sm line-clamp-2">{body}</p> 139 + )} 140 + </> 141 + ); 142 + } else if (contentType === "highlight") { 143 + const quote = item?.target?.selector?.exact; 144 + preview = quote ? ( 145 + <p className="text-surface-500 dark:text-surface-400 text-xs italic line-clamp-2"> 146 + &ldquo;{quote}&rdquo; 147 + </p> 148 + ) : null; 149 + } else if (contentType === "bookmark") { 150 + const title = item?.title || item?.target?.title; 151 + const source = item?.source || item?.target?.source; 152 + preview = ( 153 + <> 154 + {title && ( 155 + <p className="text-surface-700 dark:text-surface-300 text-sm font-medium line-clamp-1"> 156 + {title} 157 + </p> 158 + )} 159 + {source && ( 160 + <p className="text-surface-400 dark:text-surface-500 text-xs line-clamp-1 mt-0.5 flex items-center gap-1"> 161 + <ExternalLink size={10} className="shrink-0" /> 162 + {(() => { 163 + try { 164 + return new URL(source).hostname; 165 + } catch { 166 + return source; 167 + } 168 + })()} 169 + </p> 170 + )} 171 + </> 172 + ); 173 + } else if (contentType === "reply") { 174 + const text = item?.text; 175 + const parentUri = (item as any)?.inReplyTo as string | undefined; 176 + const parentIsReply = parentUri ? getContentType(parentUri) === "reply" : false; 177 + preview = ( 178 + <> 179 + {text && ( 180 + <p className="text-surface-700 dark:text-surface-300 text-sm line-clamp-2">{text}</p> 181 + )} 182 + {parentUri && ( 183 + <p className="text-surface-400 dark:text-surface-500 text-xs mt-1"> 184 + in reply to{" "} 185 + <Link 186 + to={`/annotation/${encodeURIComponent(parentUri)}`} 187 + className="hover:underline text-primary-500" 188 + onClick={(e) => e.stopPropagation()} 189 + > 190 + {parentIsReply ? "a reply" : "an annotation"} 191 + </Link> 192 + </p> 193 + )} 194 + </> 195 + ); 196 + } 197 + 198 + if (!preview) return null; 199 + 200 + return ( 201 + <Link 202 + to={href} 203 + className="block mt-2 pl-3 border-l-2 border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-500 transition-colors group" 204 + > 205 + {preview} 206 + </Link> 207 + ); 208 + } 209 + 40 210 export default function Notifications() { 41 211 const [notifications, setNotifications] = useState<NotificationItem[]>([]); 42 212 const [loading, setLoading] = useState(true); 43 213 44 214 useEffect(() => { 45 - const loadNotifications = async () => { 215 + const load = async () => { 46 216 setLoading(true); 47 217 const data = await getNotifications(); 48 218 setNotifications(data); 49 219 setLoading(false); 50 220 markNotificationsRead(); 51 221 }; 52 - loadNotifications(); 222 + load(); 53 223 }, []); 54 224 55 225 if (loading) { ··· 94 264 Activity 95 265 </h1> 96 266 <div className="space-y-2"> 97 - {notifications.map((n) => ( 98 - <div 99 - key={n.id} 100 - className={clsx( 101 - "card p-4 transition-all", 102 - !n.readAt && 103 - "ring-2 ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10", 104 - )} 105 - > 106 - <div className="flex gap-3"> 107 - <div className="shrink-0"> 108 - <NotificationIcon type={n.type} /> 109 - </div> 110 - <div className="flex-1 min-w-0"> 111 - <div className="flex items-center gap-2 flex-wrap"> 112 - <Avatar src={n.actor.avatar} size="xs" /> 113 - <span className="font-semibold text-surface-900 dark:text-white text-sm truncate"> 114 - {n.actor.displayName || n.actor.handle} 115 - </span> 116 - <span className="text-surface-500 dark:text-surface-400 text-sm"> 117 - {n.type === "like" && "liked your post"} 118 - {n.type === "reply" && "replied to you"} 119 - {n.type === "follow" && "followed you"} 120 - {n.type === "highlight" && "highlighted"} 121 - </span> 122 - <span className="text-surface-400 dark:text-surface-500 text-xs ml-auto"> 123 - {formatDistanceToNow(new Date(n.createdAt), { 124 - addSuffix: false, 125 - })} 126 - </span> 267 + {notifications.map((n) => { 268 + const contentType = getContentType(n.subjectUri || ""); 269 + const verb = getNotificationVerb(n.type, contentType, n.subject as AnnotationItem); 270 + const timeAgo = formatDistanceToNow(new Date(n.createdAt), { 271 + addSuffix: false, 272 + }); 273 + 274 + return ( 275 + <div 276 + key={n.id} 277 + className={clsx( 278 + "card p-4 transition-all", 279 + !n.readAt && 280 + "ring-2 ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10", 281 + )} 282 + > 283 + <div className="flex gap-3"> 284 + <div className="shrink-0 mt-0.5"> 285 + <NotificationIcon type={n.type} /> 127 286 </div> 287 + <div className="flex-1 min-w-0"> 288 + <div className="flex items-start gap-2 flex-wrap"> 289 + <Link 290 + to={`/profile/${n.actor.did}`} 291 + className="shrink-0" 292 + > 293 + <Avatar src={n.actor.avatar} size="xs" /> 294 + </Link> 295 + <div className="flex-1 min-w-0"> 296 + <span className="text-surface-500 dark:text-surface-400 text-sm"> 297 + <Link 298 + to={`/profile/${n.actor.did}`} 299 + className="font-semibold text-surface-900 dark:text-white hover:underline" 300 + > 301 + {n.actor.displayName || `@${n.actor.handle}`} 302 + </Link> 303 + {" "} 304 + {n.type !== "follow" && n.subjectUri ? ( 305 + <Link 306 + to={`/annotation/${encodeURIComponent(n.subjectUri)}`} 307 + className="hover:underline" 308 + > 309 + {verb} 310 + </Link> 311 + ) : ( 312 + verb 313 + )} 314 + </span> 315 + <span className="text-surface-400 dark:text-surface-500 text-xs ml-1.5"> 316 + {timeAgo} 317 + </span> 318 + </div> 319 + </div> 128 320 129 - {!!n.subject && ( 130 - <div className="mt-3 pl-3 border-l-2 border-surface-200 dark:border-surface-700"> 131 - {n.type === "reply" && 132 - (n.subject as AnnotationItem).text ? ( 133 - <p className="text-surface-600 dark:text-surface-300 text-sm"> 134 - {(n.subject as AnnotationItem).text} 135 - </p> 136 - ) : (n.subject as AnnotationItem).uri ? ( 137 - <Card item={n.subject as AnnotationItem} hideShare /> 138 - ) : null} 139 - </div> 140 - )} 321 + {n.subject !== undefined && n.subject !== null && ( 322 + <SubjectPreview 323 + subject={n.subject} 324 + subjectUri={n.subjectUri || ""} 325 + /> 326 + )} 327 + </div> 141 328 </div> 142 329 </div> 143 - </div> 144 - ))} 330 + ); 331 + })} 145 332 </div> 146 333 </div> 147 334 );