(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.

better notifications and logger

scanash00 dc4af768 071ecbe1

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