tangled
alpha
login
or
join now
margin.at
/
margin
86
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
86
fork
atom
overview
issues
4
pulls
1
pipelines
better notifications and logger
scanash.com
1 week ago
dc4af768
071ecbe1
+437
-179
16 changed files
expand all
collapse all
unified
split
backend
cmd
server
main.go
internal
api
annotations.go
apikey.go
collections.go
handler.go
hydration.go
moderation.go
semble_fetch.go
token_refresh.go
firehose
ingester.go
logger
logger.go
middleware
logger.go
oauth
handler.go
sync
service.go
xrpc
utils.go
web
src
views
core
Notifications.tsx
+11
-11
backend/cmd/server/main.go
···
2
2
3
3
import (
4
4
"context"
5
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
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
30
-
log.Fatalf("Failed to connect to database: %v", err)
30
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
35
-
log.Fatalf("Failed to run migrations: %v", err)
35
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
42
-
log.Fatalf("Failed to initialize OAuth: %v", err)
42
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
47
-
log.Printf("Firehose URL: %s", firehose.RelayURL)
47
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
51
-
log.Printf("Firehose ingester error: %v", err)
51
51
+
logger.Error("Firehose ingester error: %v", err)
52
52
}
53
53
}()
54
54
···
114
114
}
115
115
116
116
go func() {
117
117
-
log.Printf("Margin API server running on :%s", port)
117
117
+
logger.Info("Margin API server running on :%s", port)
118
118
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
119
119
-
log.Fatalf("Server error: %v", err)
119
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
127
-
log.Println("Shutting down server...")
127
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
134
-
log.Fatalf("Server forced to shutdown: %v", err)
134
134
+
logger.Fatal("Server forced to shutdown: %v", err)
135
135
}
136
136
137
137
-
log.Println("Server exited")
137
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
6
-
"log"
7
6
"net/http"
8
7
"regexp"
9
8
"strings"
10
9
"time"
11
10
12
11
"margin.at/internal/db"
12
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
223
-
log.Printf("Warning: failed to index annotation in local DB: %v", err)
223
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
228
-
log.Printf("Warning: failed to create self-label %s: %v", label, err)
228
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
274
-
log.Printf("PDS delete failed (will still clean local DB): %v", pdsErr)
274
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
341
-
log.Printf("[DEBUG] Saving edit history for %s. Previous content: %s", uri, previousContent)
341
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
343
-
log.Printf("Failed to save edit history for %s: %v", uri, err)
343
343
+
logger.Error("Failed to save edit history for %s: %v", uri, err)
344
344
} else {
345
345
-
log.Printf("[DEBUG] Successfully saved edit history for %s", uri)
345
345
+
logger.Info("[DEBUG] Successfully saved edit history for %s", uri)
346
346
}
347
347
} else {
348
348
-
log.Printf("[DEBUG] Annotation BodyValue is nil for %s", uri)
348
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
389
-
log.Printf("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr)
389
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
397
-
log.Printf("[UpdateAnnotation] Failed: %v", err)
397
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
412
-
log.Printf("Warning: failed to sync self-labels: %v", err)
412
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
613
+
if req.RootURI != req.ParentURI {
614
614
+
if rootAuthorDID, err := s.db.GetAuthorByURI(req.RootURI); err == nil && rootAuthorDID != session.DID {
615
615
+
parentAuthorDID, _ := s.db.GetAuthorByURI(req.ParentURI)
616
616
+
if rootAuthorDID != parentAuthorDID {
617
617
+
s.db.CreateNotification(&db.Notification{
618
618
+
RecipientDID: rootAuthorDID,
619
619
+
ActorDID: session.DID,
620
620
+
Type: "reply",
621
621
+
SubjectURI: result.URI,
622
622
+
CreatedAt: time.Now(),
623
623
+
})
624
624
+
}
625
625
+
}
626
626
+
}
627
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
761
-
log.Printf("Warning: failed to create self-label %s: %v", label, err)
776
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
874
-
log.Printf("PDS delete highlight failed (will still clean local DB): %v", pdsErr)
889
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
901
-
log.Printf("PDS delete bookmark failed (will still clean local DB): %v", pdsErr)
916
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
981
-
log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr)
996
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
1008
-
log.Printf("Warning: failed to sync self-labels: %v", err)
1023
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
1089
-
log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr)
1104
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
1116
-
log.Printf("Warning: failed to sync self-labels: %v", err)
1131
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
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
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
76
-
log.Printf("[ERROR] Failed to create API key record on PDS: %v", err)
76
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
95
-
log.Printf("[ERROR] Failed to insert API key into DB: %v", err)
95
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
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
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
153
-
log.Printf("Failed to add to collection in DB: %v", err)
153
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
177
-
log.Printf("Warning: PDS delete failed for %s: %v", itemURI, err)
177
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
319
-
log.Printf("Hydration error: %v", err)
319
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
377
-
log.Printf("DEBUG PutRecord failed: %v. Retrying with delete-then-create workaround for buggy PDS.", updateErr)
377
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
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
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
337
-
log.Printf("Error fetching collection items: %v\n", err)
337
337
+
logger.Error("Error fetching collection items: %v", err)
338
338
}
339
339
}
340
340
}
···
449
449
sortFeed(feed)
450
450
}
451
451
452
452
-
log.Printf("[DEBUG] FeedType: %s, Total Items before slice: %d", feedType, len(feed))
452
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
457
-
log.Printf("[DEBUG] First Item (Annotation): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount)
457
457
+
logger.Info("[DEBUG] First Item (Annotation): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount)
458
458
case APIHighlight:
459
459
-
log.Printf("[DEBUG] First Item (Highlight): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount)
459
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
786
-
log.Printf("Constellation discover error, falling back to local: %v", err)
786
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
952
-
log.Printf("Background sync error (annotations): %v", err)
952
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
992
-
log.Printf("Background sync error (highlights): %v", err)
992
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
1032
-
log.Printf("Background sync error (bookmarks): %v", err)
1032
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
1335
-
log.Printf("Failed to hydrate notifications: %v\n", err)
1335
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
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
16
+
"margin.at/internal/logger"
17
17
)
18
18
19
19
var (
···
22
22
)
23
23
24
24
func init() {
25
25
-
log.Printf("Constellation client initialized: %s", constellation.DefaultBaseURL)
25
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
191
-
log.Printf("Constellation fetch error (non-fatal): %v", err)
191
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
584
-
log.Printf("Hydration fetch error: %v\n", err)
584
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
590
-
log.Printf("Hydration fetch status error: %d\n", resp.StatusCode)
590
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
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
777
+
} else if n.Type != "follow" && n.SubjectURI != "" {
778
778
+
contentURIs = append(contentURIs, n.SubjectURI)
776
779
}
777
780
}
778
781
···
787
790
}
788
791
}
789
792
793
793
+
contentMap := make(map[string]interface{})
794
794
+
if len(contentURIs) > 0 {
795
795
+
if annotations, err := database.GetAnnotationsByURIs(contentURIs); err == nil && len(annotations) > 0 {
796
796
+
hydratedAnnotations, _ := hydrateAnnotations(database, annotations, "")
797
797
+
for _, a := range hydratedAnnotations {
798
798
+
contentMap[a.ID] = a
799
799
+
}
800
800
+
}
801
801
+
if highlights, err := database.GetHighlightsByURIs(contentURIs); err == nil && len(highlights) > 0 {
802
802
+
hydratedHighlights, _ := hydrateHighlights(database, highlights, "")
803
803
+
for _, h := range hydratedHighlights {
804
804
+
contentMap[h.ID] = h
805
805
+
}
806
806
+
}
807
807
+
if bookmarks, err := database.GetBookmarksByURIs(contentURIs); err == nil && len(bookmarks) > 0 {
808
808
+
hydratedBookmarks, _ := hydrateBookmarks(database, bookmarks, "")
809
809
+
for _, b := range hydratedBookmarks {
810
810
+
contentMap[b.ID] = b
811
811
+
}
812
812
+
}
813
813
+
}
814
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
820
+
subject = val
821
821
+
}
822
822
+
} else if n.SubjectURI != "" {
823
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
5
-
"log"
6
5
"net/http"
7
6
"strconv"
8
7
9
8
"margin.at/internal/config"
10
9
"margin.at/internal/db"
10
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
43
-
log.Printf("Failed to create block: %v", err)
43
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
66
-
log.Printf("Failed to delete block: %v", err)
66
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
134
-
log.Printf("Failed to create mute: %v", err)
134
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
157
-
log.Printf("Failed to delete mute: %v", err)
157
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
266
-
log.Printf("Failed to create report: %v", err)
266
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
392
-
log.Printf("Failed to create moderation action: %v", err)
392
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
413
-
log.Printf("Failed to resolve report: %v", err)
413
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
534
-
log.Printf("Failed to create content label: %v", err)
534
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
7
-
"log"
8
7
"net/http"
9
8
"strings"
10
9
"sync"
11
10
"time"
12
11
13
12
"margin.at/internal/db"
13
13
+
"margin.at/internal/logger"
14
14
"margin.at/internal/xrpc"
15
15
)
16
16
···
56
56
return
57
57
}
58
58
59
59
-
log.Printf("Active Cache: Fetching %d missing Semble cards...", len(missing))
59
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
87
-
log.Printf("Failed to lazy fetch card %s: %v", u, err)
87
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
11
-
"log"
12
11
"net/http"
13
12
"os"
14
13
"time"
15
14
16
15
"margin.at/internal/db"
16
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
159
-
log.Printf("Successfully refreshed token for user %s", session.Handle)
159
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
197
-
log.Printf("Token expired for user %s, attempting refresh...", session.Handle)
197
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
201
-
log.Printf("Token refresh failed for user %s, invalidating session: %v", session.Handle, refreshErr)
201
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
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
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
107
-
log.Printf("Jetstream error (relay %d): %v, reconnecting in 5s...", i.currentRelayIdx, err)
107
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
111
-
log.Printf("Switching to relay %d: %s", i.currentRelayIdx, RelayURLs[i.currentRelayIdx])
111
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
156
-
log.Printf("Connecting to Jetstream: %s", url)
156
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
164
-
log.Printf("Connected to Jetstream")
164
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
188
-
log.Printf("Failed to save cursor: %v", err)
188
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
204
-
log.Printf("CID verification failed for %s: %v (skipping)", uri, err)
204
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
257
-
log.Printf("Auto-synced repo for active user: %s", did)
257
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
297
-
log.Printf("Failed to get last cursor from DB: %v", err)
297
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
413
-
log.Printf("Failed to index annotation: %v", err)
413
413
+
logger.Error("Failed to index annotation: %v", err)
414
414
} else {
415
415
-
log.Printf("Indexed annotation from %s on %s", event.Repo, targetSource)
415
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
545
-
log.Printf("Failed to index highlight: %v", err)
545
545
+
logger.Error("Failed to index highlight: %v", err)
546
546
} else {
547
547
-
log.Printf("Indexed highlight from %s on %s", event.Repo, record.Target.Source)
547
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
603
-
log.Printf("Failed to index bookmark: %v", err)
603
603
+
logger.Error("Failed to index bookmark: %v", err)
604
604
} else {
605
605
-
log.Printf("Indexed bookmark from %s: %s", event.Repo, record.Source)
605
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
647
-
log.Printf("Failed to index collection: %v", err)
647
647
+
logger.Error("Failed to index collection: %v", err)
648
648
} else {
649
649
-
log.Printf("Indexed collection from %s: %s", event.Repo, record.Name)
649
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
683
-
log.Printf("Failed to index collection item: %v", err)
683
683
+
logger.Error("Failed to index collection item: %v", err)
684
684
} else {
685
685
-
log.Printf("Indexed collection item from %s", event.Repo)
685
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
741
-
log.Printf("Failed to index profile: %v", err)
741
741
+
logger.Error("Failed to index profile: %v", err)
742
742
} else {
743
743
-
log.Printf("Indexed profile from %s", event.Repo)
743
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
782
-
log.Printf("Failed to index API key: %v", err)
782
782
+
logger.Error("Failed to index API key: %v", err)
783
783
} else {
784
784
-
log.Printf("Indexed API key from %s: %s", event.Repo, record.Name)
784
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
849
-
log.Printf("Failed to index preferences: %v", err)
849
849
+
logger.Error("Failed to index preferences: %v", err)
850
850
} else {
851
851
-
log.Printf("Indexed preferences from %s", event.Repo)
851
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
918
-
log.Printf("Failed to index Semble NOTE as annotation: %v", err)
918
918
+
logger.Error("Failed to index Semble NOTE as annotation: %v", err)
919
919
} else {
920
920
if card.ParentCard != nil {
921
921
-
log.Printf("Indexed Semble NOTE from %s on %s (Parent: %s)", event.Repo, targetSource, card.ParentCard.URI)
921
921
+
logger.Info("Indexed Semble NOTE from %s on %s (Parent: %s)", event.Repo, targetSource, card.ParentCard.URI)
922
922
} else {
923
923
-
log.Printf("Indexed Semble NOTE from %s on %s", event.Repo, targetSource)
923
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
955
-
log.Printf("Failed to index Semble URL as bookmark: %v", err)
955
955
+
logger.Error("Failed to index Semble URL as bookmark: %v", err)
956
956
} else {
957
957
-
log.Printf("Indexed Semble URL from %s: %s", event.Repo, source)
957
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
992
-
log.Printf("Failed to index Semble collection: %v", err)
992
992
+
logger.Error("Failed to index Semble collection: %v", err)
993
993
} else {
994
994
-
log.Printf("Indexed Semble collection from %s: %s", event.Repo, record.Name)
994
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
1021
-
log.Printf("Failed to index Semble collection link: %v", err)
1021
1021
+
logger.Error("Failed to index Semble collection link: %v", err)
1022
1022
} else {
1023
1023
-
log.Printf("Indexed Semble collection link from %s", event.Repo)
1023
1023
+
logger.Info("Indexed Semble collection link from %s", event.Repo)
1024
1024
}
1025
1025
}
+27
backend/internal/logger/logger.go
···
1
1
+
package logger
2
2
+
3
3
+
import (
4
4
+
"log"
5
5
+
"os"
6
6
+
)
7
7
+
8
8
+
var (
9
9
+
infoLog = log.New(os.Stdout, "", log.LstdFlags)
10
10
+
errorLog = log.New(os.Stderr, "", log.LstdFlags)
11
11
+
)
12
12
+
13
13
+
func Info(format string, args ...any) {
14
14
+
infoLog.Printf(format, args...)
15
15
+
}
16
16
+
17
17
+
func Infoln(msg string) {
18
18
+
infoLog.Println(msg)
19
19
+
}
20
20
+
21
21
+
func Error(format string, args ...any) {
22
22
+
errorLog.Printf(format, args...)
23
23
+
}
24
24
+
25
25
+
func Fatal(format string, args ...any) {
26
26
+
errorLog.Fatalf(format, args...)
27
27
+
}
+2
-2
backend/internal/middleware/logger.go
···
1
1
package middleware
2
2
3
3
import (
4
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
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
21
-
log.Printf("[%d] %s %s %s",
21
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
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
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
84
-
log.Printf("Warning: could not save key to %s: %v\n", keyPath, err)
84
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
243
-
log.Printf("PAR request failed: %v", err)
243
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
303
-
log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err)
303
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
324
-
log.Printf("prompt=create not supported, falling back to standard flow")
324
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
329
-
log.Printf("PAR request failed for signup: %v", err)
329
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
371
-
log.Printf("OAuth callback error: %s - %s", oauthErr, errDesc)
371
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
417
-
log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err)
417
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
429
-
log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub)
429
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
472
-
log.Printf("Starting background sync for %s...", tokenResp.Sub)
472
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
478
-
log.Printf("Background sync failed for %s: %v", tokenResp.Sub, err)
478
478
+
logger.Error("Background sync failed for %s: %v", tokenResp.Sub, err)
479
479
} else {
480
480
-
log.Printf("Background sync completed for %s", tokenResp.Sub)
480
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
547
-
log.Printf("Failed to delete orphaned reply from PDS: %v", err)
547
547
+
logger.Error("Failed to delete orphaned reply from PDS: %v", err)
548
548
} else {
549
549
-
log.Printf("Cleaned up orphaned reply %s/%s from PDS", collection, rkey)
549
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
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
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
93
-
log.Printf("CID verification failed for %s: %v (skipping)", rec.URI, err)
93
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
7
-
"log"
8
7
"net/http"
9
8
"regexp"
10
9
"strings"
11
10
"time"
12
11
13
12
"margin.at/internal/config"
13
13
+
"margin.at/internal/logger"
14
14
"margin.at/internal/slingshot"
15
15
)
16
16
···
84
84
}
85
85
86
86
func init() {
87
87
-
log.Printf("Slingshot client initialized: %s", slingshot.DefaultBaseURL)
87
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
2
+
import { Link } from "react-router-dom";
2
3
import { getNotifications, markNotificationsRead } from "../../api/client";
3
4
import type { NotificationItem, AnnotationItem } from "../../types";
4
4
-
import { Heart, MessageCircle, Bell, PenTool } from "lucide-react";
5
5
-
import Card from "../../components/common/Card";
5
5
+
import {
6
6
+
Heart,
7
7
+
MessageCircle,
8
8
+
Bell,
9
9
+
PenTool,
10
10
+
Bookmark,
11
11
+
UserPlus,
12
12
+
AtSign,
13
13
+
ExternalLink,
14
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
19
+
function getContentType(
20
20
+
uri: string,
21
21
+
): "annotation" | "highlight" | "bookmark" | "reply" | "unknown" {
22
22
+
if (uri.includes("/at.margin.annotation/")) return "annotation";
23
23
+
if (uri.includes("/at.margin.highlight/")) return "highlight";
24
24
+
if (uri.includes("/at.margin.bookmark/")) return "bookmark";
25
25
+
if (uri.includes("/at.margin.reply/")) return "reply";
26
26
+
return "unknown";
27
27
+
}
28
28
+
29
29
+
function getNotificationVerb(
30
30
+
notifType: string,
31
31
+
contentType: string,
32
32
+
subject?: AnnotationItem,
33
33
+
): string {
34
34
+
switch (notifType) {
35
35
+
case "like":
36
36
+
switch (contentType) {
37
37
+
case "annotation":
38
38
+
return "liked your annotation";
39
39
+
case "highlight":
40
40
+
return "liked your highlight";
41
41
+
case "bookmark":
42
42
+
return "liked your bookmark";
43
43
+
case "reply":
44
44
+
return "liked your reply";
45
45
+
default:
46
46
+
return "liked your post";
47
47
+
}
48
48
+
case "reply": {
49
49
+
const parentUri = (subject as any)?.inReplyTo as string | undefined;
50
50
+
const parentIsReply = parentUri ? getContentType(parentUri) === "reply" : false;
51
51
+
return parentIsReply ? "replied to your reply" : "replied to your annotation";
52
52
+
}
53
53
+
case "mention":
54
54
+
return "mentioned you in an annotation";
55
55
+
case "follow":
56
56
+
return "followed you";
57
57
+
case "highlight":
58
58
+
return "highlighted your page";
59
59
+
default:
60
60
+
return notifType;
61
61
+
}
62
62
+
}
63
63
+
10
64
const NotificationIcon = ({ type }: { type: string }) => {
11
11
-
const iconClass = "p-2 rounded-full";
65
65
+
const base = "p-2 rounded-full";
12
66
switch (type) {
13
67
case "like":
14
68
return (
15
15
-
<div className={clsx(iconClass, "bg-red-100 dark:bg-red-900/30")}>
16
16
-
<Heart size={16} className="text-red-500" />
69
69
+
<div className={clsx(base, "bg-red-100 dark:bg-red-900/30")}>
70
70
+
<Heart size={15} className="text-red-500" />
17
71
</div>
18
72
);
19
73
case "reply":
20
74
return (
21
21
-
<div className={clsx(iconClass, "bg-blue-100 dark:bg-blue-900/30")}>
22
22
-
<MessageCircle size={16} className="text-blue-500" />
75
75
+
<div className={clsx(base, "bg-blue-100 dark:bg-blue-900/30")}>
76
76
+
<MessageCircle size={15} className="text-blue-500" />
23
77
</div>
24
78
);
25
79
case "highlight":
26
80
return (
27
27
-
<div className={clsx(iconClass, "bg-yellow-100 dark:bg-yellow-900/30")}>
28
28
-
<PenTool size={16} className="text-yellow-600" />
81
81
+
<div className={clsx(base, "bg-yellow-100 dark:bg-yellow-900/30")}>
82
82
+
<PenTool size={15} className="text-yellow-600" />
83
83
+
</div>
84
84
+
);
85
85
+
case "bookmark":
86
86
+
return (
87
87
+
<div className={clsx(base, "bg-green-100 dark:bg-green-900/30")}>
88
88
+
<Bookmark size={15} className="text-green-600" />
89
89
+
</div>
90
90
+
);
91
91
+
case "follow":
92
92
+
return (
93
93
+
<div className={clsx(base, "bg-purple-100 dark:bg-purple-900/30")}>
94
94
+
<UserPlus size={15} className="text-purple-500" />
95
95
+
</div>
96
96
+
);
97
97
+
case "mention":
98
98
+
return (
99
99
+
<div className={clsx(base, "bg-indigo-100 dark:bg-indigo-900/30")}>
100
100
+
<AtSign size={15} className="text-indigo-500" />
29
101
</div>
30
102
);
31
103
default:
32
104
return (
33
33
-
<div className={clsx(iconClass, "bg-surface-100 dark:bg-surface-800")}>
34
34
-
<Bell size={16} className="text-surface-500" />
105
105
+
<div className={clsx(base, "bg-surface-100 dark:bg-surface-800")}>
106
106
+
<Bell size={15} className="text-surface-500" />
35
107
</div>
36
108
);
37
109
}
38
110
};
39
111
112
112
+
function SubjectPreview({
113
113
+
subject,
114
114
+
subjectUri,
115
115
+
}: {
116
116
+
subject: AnnotationItem | unknown;
117
117
+
subjectUri: string;
118
118
+
}) {
119
119
+
const item = subject as AnnotationItem | undefined;
120
120
+
if (!item?.uri && !subjectUri) return null;
121
121
+
122
122
+
const contentType = getContentType(subjectUri);
123
123
+
const href = `/annotation/${encodeURIComponent(subjectUri)}`;
124
124
+
125
125
+
let preview: React.ReactNode = null;
126
126
+
127
127
+
if (contentType === "annotation") {
128
128
+
const quote = item?.target?.selector?.exact;
129
129
+
const body = item?.text || item?.body?.value;
130
130
+
preview = (
131
131
+
<>
132
132
+
{quote && (
133
133
+
<p className="text-surface-500 dark:text-surface-400 text-xs italic line-clamp-2 mb-1">
134
134
+
“{quote}”
135
135
+
</p>
136
136
+
)}
137
137
+
{body && (
138
138
+
<p className="text-surface-700 dark:text-surface-300 text-sm line-clamp-2">{body}</p>
139
139
+
)}
140
140
+
</>
141
141
+
);
142
142
+
} else if (contentType === "highlight") {
143
143
+
const quote = item?.target?.selector?.exact;
144
144
+
preview = quote ? (
145
145
+
<p className="text-surface-500 dark:text-surface-400 text-xs italic line-clamp-2">
146
146
+
“{quote}”
147
147
+
</p>
148
148
+
) : null;
149
149
+
} else if (contentType === "bookmark") {
150
150
+
const title = item?.title || item?.target?.title;
151
151
+
const source = item?.source || item?.target?.source;
152
152
+
preview = (
153
153
+
<>
154
154
+
{title && (
155
155
+
<p className="text-surface-700 dark:text-surface-300 text-sm font-medium line-clamp-1">
156
156
+
{title}
157
157
+
</p>
158
158
+
)}
159
159
+
{source && (
160
160
+
<p className="text-surface-400 dark:text-surface-500 text-xs line-clamp-1 mt-0.5 flex items-center gap-1">
161
161
+
<ExternalLink size={10} className="shrink-0" />
162
162
+
{(() => {
163
163
+
try {
164
164
+
return new URL(source).hostname;
165
165
+
} catch {
166
166
+
return source;
167
167
+
}
168
168
+
})()}
169
169
+
</p>
170
170
+
)}
171
171
+
</>
172
172
+
);
173
173
+
} else if (contentType === "reply") {
174
174
+
const text = item?.text;
175
175
+
const parentUri = (item as any)?.inReplyTo as string | undefined;
176
176
+
const parentIsReply = parentUri ? getContentType(parentUri) === "reply" : false;
177
177
+
preview = (
178
178
+
<>
179
179
+
{text && (
180
180
+
<p className="text-surface-700 dark:text-surface-300 text-sm line-clamp-2">{text}</p>
181
181
+
)}
182
182
+
{parentUri && (
183
183
+
<p className="text-surface-400 dark:text-surface-500 text-xs mt-1">
184
184
+
in reply to{" "}
185
185
+
<Link
186
186
+
to={`/annotation/${encodeURIComponent(parentUri)}`}
187
187
+
className="hover:underline text-primary-500"
188
188
+
onClick={(e) => e.stopPropagation()}
189
189
+
>
190
190
+
{parentIsReply ? "a reply" : "an annotation"}
191
191
+
</Link>
192
192
+
</p>
193
193
+
)}
194
194
+
</>
195
195
+
);
196
196
+
}
197
197
+
198
198
+
if (!preview) return null;
199
199
+
200
200
+
return (
201
201
+
<Link
202
202
+
to={href}
203
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
204
+
>
205
205
+
{preview}
206
206
+
</Link>
207
207
+
);
208
208
+
}
209
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
45
-
const loadNotifications = async () => {
215
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
52
-
loadNotifications();
222
222
+
load();
53
223
}, []);
54
224
55
225
if (loading) {
···
94
264
Activity
95
265
</h1>
96
266
<div className="space-y-2">
97
97
-
{notifications.map((n) => (
98
98
-
<div
99
99
-
key={n.id}
100
100
-
className={clsx(
101
101
-
"card p-4 transition-all",
102
102
-
!n.readAt &&
103
103
-
"ring-2 ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10",
104
104
-
)}
105
105
-
>
106
106
-
<div className="flex gap-3">
107
107
-
<div className="shrink-0">
108
108
-
<NotificationIcon type={n.type} />
109
109
-
</div>
110
110
-
<div className="flex-1 min-w-0">
111
111
-
<div className="flex items-center gap-2 flex-wrap">
112
112
-
<Avatar src={n.actor.avatar} size="xs" />
113
113
-
<span className="font-semibold text-surface-900 dark:text-white text-sm truncate">
114
114
-
{n.actor.displayName || n.actor.handle}
115
115
-
</span>
116
116
-
<span className="text-surface-500 dark:text-surface-400 text-sm">
117
117
-
{n.type === "like" && "liked your post"}
118
118
-
{n.type === "reply" && "replied to you"}
119
119
-
{n.type === "follow" && "followed you"}
120
120
-
{n.type === "highlight" && "highlighted"}
121
121
-
</span>
122
122
-
<span className="text-surface-400 dark:text-surface-500 text-xs ml-auto">
123
123
-
{formatDistanceToNow(new Date(n.createdAt), {
124
124
-
addSuffix: false,
125
125
-
})}
126
126
-
</span>
267
267
+
{notifications.map((n) => {
268
268
+
const contentType = getContentType(n.subjectUri || "");
269
269
+
const verb = getNotificationVerb(n.type, contentType, n.subject as AnnotationItem);
270
270
+
const timeAgo = formatDistanceToNow(new Date(n.createdAt), {
271
271
+
addSuffix: false,
272
272
+
});
273
273
+
274
274
+
return (
275
275
+
<div
276
276
+
key={n.id}
277
277
+
className={clsx(
278
278
+
"card p-4 transition-all",
279
279
+
!n.readAt &&
280
280
+
"ring-2 ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10",
281
281
+
)}
282
282
+
>
283
283
+
<div className="flex gap-3">
284
284
+
<div className="shrink-0 mt-0.5">
285
285
+
<NotificationIcon type={n.type} />
127
286
</div>
287
287
+
<div className="flex-1 min-w-0">
288
288
+
<div className="flex items-start gap-2 flex-wrap">
289
289
+
<Link
290
290
+
to={`/profile/${n.actor.did}`}
291
291
+
className="shrink-0"
292
292
+
>
293
293
+
<Avatar src={n.actor.avatar} size="xs" />
294
294
+
</Link>
295
295
+
<div className="flex-1 min-w-0">
296
296
+
<span className="text-surface-500 dark:text-surface-400 text-sm">
297
297
+
<Link
298
298
+
to={`/profile/${n.actor.did}`}
299
299
+
className="font-semibold text-surface-900 dark:text-white hover:underline"
300
300
+
>
301
301
+
{n.actor.displayName || `@${n.actor.handle}`}
302
302
+
</Link>
303
303
+
{" "}
304
304
+
{n.type !== "follow" && n.subjectUri ? (
305
305
+
<Link
306
306
+
to={`/annotation/${encodeURIComponent(n.subjectUri)}`}
307
307
+
className="hover:underline"
308
308
+
>
309
309
+
{verb}
310
310
+
</Link>
311
311
+
) : (
312
312
+
verb
313
313
+
)}
314
314
+
</span>
315
315
+
<span className="text-surface-400 dark:text-surface-500 text-xs ml-1.5">
316
316
+
{timeAgo}
317
317
+
</span>
318
318
+
</div>
319
319
+
</div>
128
320
129
129
-
{!!n.subject && (
130
130
-
<div className="mt-3 pl-3 border-l-2 border-surface-200 dark:border-surface-700">
131
131
-
{n.type === "reply" &&
132
132
-
(n.subject as AnnotationItem).text ? (
133
133
-
<p className="text-surface-600 dark:text-surface-300 text-sm">
134
134
-
{(n.subject as AnnotationItem).text}
135
135
-
</p>
136
136
-
) : (n.subject as AnnotationItem).uri ? (
137
137
-
<Card item={n.subject as AnnotationItem} hideShare />
138
138
-
) : null}
139
139
-
</div>
140
140
-
)}
321
321
+
{n.subject !== undefined && n.subject !== null && (
322
322
+
<SubjectPreview
323
323
+
subject={n.subject}
324
324
+
subjectUri={n.subjectUri || ""}
325
325
+
/>
326
326
+
)}
327
327
+
</div>
141
328
</div>
142
329
</div>
143
143
-
</div>
144
144
-
))}
330
330
+
);
331
331
+
})}
145
332
</div>
146
333
</div>
147
334
);