tangled
alpha
login
or
join now
margin.at
/
margin
90
fork
atom
Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
90
fork
atom
overview
issues
4
pulls
1
pipelines
fixes and optimizations that may or may not break margin
scanash.com
4 days ago
0c7526b0
cc1fc4f2
+963
-466
29 changed files
expand all
collapse all
unified
split
backend
internal
api
handler.go
hydration.go
semble_fetch.go
constellation
client.go
firehose
ingester.go
slingshot
client.go
verification
verify.go
web
src
components
common
Card.tsx
feed
FeedItems.tsx
modals
AddToCollectionModal.tsx
EditCollectionModal.tsx
ShareMenu.tsx
pages
[handle]
annotation
[rkey].astro
bookmark
[rkey].astro
collection
[rkey].astro
highlight
[rkey].astro
at
[did]
[rkey].astro
collections
[rkey].astro
profile
[did].astro
search.astro
store
auth.ts
views
collections
CollectionDetail.tsx
Collections.tsx
content
AnnotationDetail.tsx
core
Discover.tsx
Feed.tsx
Notifications.tsx
Search.tsx
profile
Profile.tsx
+27
-18
backend/internal/api/handler.go
···
248
248
249
249
fetchLimit := limit + offset
250
250
251
251
+
perTypeFetchLimit := fetchLimit
252
252
+
if motivation == "" {
253
253
+
perTypeFetchLimit = fetchLimit/2 + 10
254
254
+
}
255
255
+
251
256
if tag != "" {
252
257
if creator != "" {
253
258
if motivation == "" || motivation == "commenting" {
···
347
352
}
348
353
collectionItems = []db.CollectionItem{}
349
354
} else {
355
355
+
typeLim := fetchLimit
356
356
+
if motivation == "" {
357
357
+
typeLim = perTypeFetchLimit
358
358
+
}
350
359
if motivation == "" || motivation == "commenting" {
351
360
switch feedType {
352
361
case "margin":
353
353
-
annotations, _ = h.db.GetMarginAnnotations(fetchLimit, 0)
362
362
+
annotations, _ = h.db.GetMarginAnnotations(typeLim, 0)
354
363
case "semble":
355
355
-
annotations, _ = h.db.GetSembleAnnotations(fetchLimit, 0)
364
364
+
annotations, _ = h.db.GetSembleAnnotations(typeLim, 0)
356
365
case "popular":
357
357
-
annotations, _ = h.db.GetPopularAnnotations(fetchLimit, 0)
366
366
+
annotations, _ = h.db.GetPopularAnnotations(typeLim, 0)
358
367
case "shelved":
359
359
-
annotations, _ = h.db.GetShelvedAnnotations(fetchLimit, 0)
368
368
+
annotations, _ = h.db.GetShelvedAnnotations(typeLim, 0)
360
369
default:
361
361
-
annotations, _ = h.db.GetRecentAnnotations(fetchLimit, 0)
370
370
+
annotations, _ = h.db.GetRecentAnnotations(typeLim, 0)
362
371
}
363
372
}
364
373
if motivation == "" || motivation == "highlighting" {
365
374
switch feedType {
366
375
case "margin":
367
367
-
highlights, _ = h.db.GetMarginHighlights(fetchLimit, 0)
376
376
+
highlights, _ = h.db.GetMarginHighlights(typeLim, 0)
368
377
case "semble":
369
369
-
highlights, _ = h.db.GetSembleHighlights(fetchLimit, 0)
378
378
+
highlights, _ = h.db.GetSembleHighlights(typeLim, 0)
370
379
case "popular":
371
371
-
highlights, _ = h.db.GetPopularHighlights(fetchLimit, 0)
380
380
+
highlights, _ = h.db.GetPopularHighlights(typeLim, 0)
372
381
case "shelved":
373
373
-
highlights, _ = h.db.GetShelvedHighlights(fetchLimit, 0)
382
382
+
highlights, _ = h.db.GetShelvedHighlights(typeLim, 0)
374
383
default:
375
375
-
highlights, _ = h.db.GetRecentHighlights(fetchLimit, 0)
384
384
+
highlights, _ = h.db.GetRecentHighlights(typeLim, 0)
376
385
}
377
386
}
378
387
if motivation == "" || motivation == "bookmarking" {
379
388
switch feedType {
380
389
case "margin":
381
381
-
bookmarks, _ = h.db.GetMarginBookmarks(fetchLimit, 0)
390
390
+
bookmarks, _ = h.db.GetMarginBookmarks(typeLim, 0)
382
391
case "semble":
383
383
-
bookmarks, _ = h.db.GetSembleBookmarks(fetchLimit, 0)
392
392
+
bookmarks, _ = h.db.GetSembleBookmarks(typeLim, 0)
384
393
case "popular":
385
385
-
bookmarks, _ = h.db.GetPopularBookmarks(fetchLimit, 0)
394
394
+
bookmarks, _ = h.db.GetPopularBookmarks(typeLim, 0)
386
395
case "shelved":
387
387
-
bookmarks, _ = h.db.GetShelvedBookmarks(fetchLimit, 0)
396
396
+
bookmarks, _ = h.db.GetShelvedBookmarks(typeLim, 0)
388
397
default:
389
389
-
bookmarks, _ = h.db.GetRecentBookmarks(fetchLimit, 0)
398
398
+
bookmarks, _ = h.db.GetRecentBookmarks(typeLim, 0)
390
399
}
391
400
}
392
401
if motivation == "" {
393
402
switch feedType {
394
403
case "popular":
395
395
-
collectionItems, err = h.db.GetPopularCollectionItems(fetchLimit, 0)
404
404
+
collectionItems, err = h.db.GetPopularCollectionItems(typeLim, 0)
396
405
case "shelved":
397
397
-
collectionItems, err = h.db.GetShelvedCollectionItems(fetchLimit, 0)
406
406
+
collectionItems, err = h.db.GetShelvedCollectionItems(typeLim, 0)
398
407
default:
399
399
-
collectionItems, err = h.db.GetRecentCollectionItems(fetchLimit, 0)
408
408
+
collectionItems, err = h.db.GetRecentCollectionItems(typeLim, 0)
400
409
}
401
410
if err != nil {
402
411
logger.Error("Error fetching collection items: %v", err)
+117
-19
backend/internal/api/hydration.go
···
14
14
"margin.at/internal/constellation"
15
15
"margin.at/internal/db"
16
16
"margin.at/internal/logger"
17
17
+
"margin.at/internal/xrpc"
17
18
)
18
19
19
20
var (
···
654
655
}
655
656
656
657
if len(missingDIDs) > 0 {
657
657
-
batchSize := 25
658
658
+
// Batch fetch from bsky.social (fast — 1 HTTP call per 25 DIDs)
658
659
var wg sync.WaitGroup
659
660
var mu sync.Mutex
661
661
+
batchSize := 25
660
662
661
663
for i := 0; i < len(missingDIDs); i += batchSize {
662
664
end := i + batchSize
···
680
682
}(batch)
681
683
}
682
684
wg.Wait()
685
685
+
686
686
+
// Fallback: resolve stragglers via Slingshot (individual calls)
687
687
+
stillMissing := make([]string, 0)
688
688
+
for _, did := range missingDIDs {
689
689
+
if p, ok := profiles[did]; !ok || p.Handle == "" {
690
690
+
stillMissing = append(stillMissing, did)
691
691
+
}
692
692
+
}
693
693
+
694
694
+
if len(stillMissing) > 0 {
695
695
+
sem := make(chan struct{}, 5)
696
696
+
for _, did := range stillMissing {
697
697
+
wg.Add(1)
698
698
+
go func(d string) {
699
699
+
defer wg.Done()
700
700
+
sem <- struct{}{}
701
701
+
defer func() { <-sem }()
702
702
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
703
703
+
defer cancel()
704
704
+
identity, err := xrpc.SlingshotClient.ResolveIdentity(ctx, d)
705
705
+
if err != nil || identity.Handle == "" {
706
706
+
return
707
707
+
}
708
708
+
mu.Lock()
709
709
+
author := profiles[d]
710
710
+
author.DID = d
711
711
+
author.Handle = identity.Handle
712
712
+
profiles[d] = author
713
713
+
Cache.Set(d, author)
714
714
+
mu.Unlock()
715
715
+
}(did)
716
716
+
}
717
717
+
wg.Wait()
718
718
+
}
683
719
}
684
720
685
721
if database != nil && len(dids) > 0 {
···
822
858
mu sync.Mutex
823
859
)
824
860
825
825
-
nestedShared := &hydrationData{profiles: profiles}
861
861
+
// Fetch raw items first to collect all author DIDs
862
862
+
var rawAnnos []db.Annotation
863
863
+
var rawHighlights []db.Highlight
864
864
+
var rawBookmarks []db.Bookmark
826
865
827
866
if len(annotationURIs) > 0 {
828
867
wg.Add(1)
829
868
go func() {
830
869
defer wg.Done()
831
831
-
rawAnnos, err := database.GetAnnotationsByURIs(annotationURIs)
870
870
+
result, err := database.GetAnnotationsByURIs(annotationURIs)
832
871
if err == nil {
833
833
-
hydrated, _ := hydrateAnnotationsWithData(database, rawAnnos, viewerDID, nestedShared)
834
872
mu.Lock()
835
835
-
for _, a := range hydrated {
836
836
-
annotationsMap[a.ID] = a
837
837
-
}
873
873
+
rawAnnos = result
838
874
mu.Unlock()
839
875
}
840
876
}()
841
877
}
842
842
-
843
878
if len(highlightURIs) > 0 {
844
879
wg.Add(1)
845
880
go func() {
846
881
defer wg.Done()
847
847
-
rawHighlights, err := database.GetHighlightsByURIs(highlightURIs)
882
882
+
result, err := database.GetHighlightsByURIs(highlightURIs)
848
883
if err == nil {
849
849
-
hydrated, _ := hydrateHighlightsWithData(database, rawHighlights, viewerDID, nestedShared)
850
884
mu.Lock()
851
851
-
for _, h := range hydrated {
852
852
-
highlightsMap[h.ID] = h
853
853
-
}
885
885
+
rawHighlights = result
854
886
mu.Unlock()
855
887
}
856
888
}()
857
889
}
858
858
-
859
890
if len(bookmarkURIs) > 0 {
860
891
wg.Add(1)
861
892
go func() {
862
893
defer wg.Done()
863
863
-
rawBookmarks, err := database.GetBookmarksByURIs(bookmarkURIs)
894
894
+
result, err := database.GetBookmarksByURIs(bookmarkURIs)
864
895
if err == nil {
865
865
-
hydrated, _ := hydrateBookmarksWithData(database, rawBookmarks, viewerDID, nestedShared)
866
896
mu.Lock()
867
867
-
for _, b := range hydrated {
868
868
-
bookmarksMap[b.ID] = b
869
869
-
}
897
897
+
rawBookmarks = result
870
898
mu.Unlock()
871
899
}
900
900
+
}()
901
901
+
}
902
902
+
wg.Wait()
903
903
+
904
904
+
// Collect missing author DIDs from nested items and fetch their profiles
905
905
+
missingDIDs := make(map[string]bool)
906
906
+
for _, a := range rawAnnos {
907
907
+
if _, ok := profiles[a.AuthorDID]; !ok {
908
908
+
missingDIDs[a.AuthorDID] = true
909
909
+
}
910
910
+
}
911
911
+
for _, h := range rawHighlights {
912
912
+
if _, ok := profiles[h.AuthorDID]; !ok {
913
913
+
missingDIDs[h.AuthorDID] = true
914
914
+
}
915
915
+
}
916
916
+
for _, b := range rawBookmarks {
917
917
+
if _, ok := profiles[b.AuthorDID]; !ok {
918
918
+
missingDIDs[b.AuthorDID] = true
919
919
+
}
920
920
+
}
921
921
+
if len(missingDIDs) > 0 {
922
922
+
dids := make([]string, 0, len(missingDIDs))
923
923
+
for did := range missingDIDs {
924
924
+
dids = append(dids, did)
925
925
+
}
926
926
+
extra := fetchProfilesForDIDs(database, dids)
927
927
+
for did, prof := range extra {
928
928
+
profiles[did] = prof
929
929
+
}
930
930
+
}
931
931
+
932
932
+
nestedShared := &hydrationData{profiles: profiles}
933
933
+
934
934
+
if len(rawAnnos) > 0 {
935
935
+
wg.Add(1)
936
936
+
go func() {
937
937
+
defer wg.Done()
938
938
+
hydrated, _ := hydrateAnnotationsWithData(database, rawAnnos, viewerDID, nestedShared)
939
939
+
mu.Lock()
940
940
+
for _, a := range hydrated {
941
941
+
annotationsMap[a.ID] = a
942
942
+
}
943
943
+
mu.Unlock()
944
944
+
}()
945
945
+
}
946
946
+
947
947
+
if len(rawHighlights) > 0 {
948
948
+
wg.Add(1)
949
949
+
go func() {
950
950
+
defer wg.Done()
951
951
+
hydrated, _ := hydrateHighlightsWithData(database, rawHighlights, viewerDID, nestedShared)
952
952
+
mu.Lock()
953
953
+
for _, h := range hydrated {
954
954
+
highlightsMap[h.ID] = h
955
955
+
}
956
956
+
mu.Unlock()
957
957
+
}()
958
958
+
}
959
959
+
960
960
+
if len(rawBookmarks) > 0 {
961
961
+
wg.Add(1)
962
962
+
go func() {
963
963
+
defer wg.Done()
964
964
+
hydrated, _ := hydrateBookmarksWithData(database, rawBookmarks, viewerDID, nestedShared)
965
965
+
mu.Lock()
966
966
+
for _, b := range hydrated {
967
967
+
bookmarksMap[b.ID] = b
968
968
+
}
969
969
+
mu.Unlock()
872
970
}()
873
971
}
874
972
+57
-5
backend/internal/api/semble_fetch.go
···
14
14
"margin.at/internal/xrpc"
15
15
)
16
16
17
17
+
var (
18
18
+
failedCardsMu sync.RWMutex
19
19
+
failedCards = make(map[string]time.Time)
20
20
+
)
21
21
+
22
22
+
func isRecentlyFailed(uri string) bool {
23
23
+
failedCardsMu.RLock()
24
24
+
t, ok := failedCards[uri]
25
25
+
failedCardsMu.RUnlock()
26
26
+
return ok && time.Since(t) < 30*time.Minute
27
27
+
}
28
28
+
29
29
+
func markFailed(uri string) {
30
30
+
failedCardsMu.Lock()
31
31
+
failedCards[uri] = time.Now()
32
32
+
failedCardsMu.Unlock()
33
33
+
}
34
34
+
35
35
+
func init() {
36
36
+
go func() {
37
37
+
for {
38
38
+
time.Sleep(10 * time.Minute)
39
39
+
failedCardsMu.Lock()
40
40
+
for uri, t := range failedCards {
41
41
+
if time.Since(t) > 30*time.Minute {
42
42
+
delete(failedCards, uri)
43
43
+
}
44
44
+
}
45
45
+
failedCardsMu.Unlock()
46
46
+
}
47
47
+
}()
48
48
+
}
49
49
+
17
50
func ensureSembleCardsIndexed(ctx context.Context, database *db.DB, uris []string) {
18
51
if len(uris) == 0 || database == nil {
19
52
return
···
48
81
49
82
missing := make([]string, 0)
50
83
for _, u := range deduped {
51
51
-
if !foundSet[u] {
84
84
+
if !foundSet[u] && !isRecentlyFailed(u) {
52
85
missing = append(missing, u)
53
86
}
54
87
}
···
83
116
}
84
117
85
118
if err := fetchSembleCard(ctx, database, u); err != nil {
119
119
+
markFailed(u)
86
120
if ctx.Err() == nil {
87
121
logger.Error("Failed to lazy fetch card %s: %v", u, err)
88
122
}
···
116
150
if len(parts) < 3 {
117
151
return fmt.Errorf("invalid uri parts: expected at least 3 parts")
118
152
}
119
119
-
did, collection, rkey := parts[0], parts[1], parts[2]
153
153
+
did, _, _ := parts[0], parts[1], parts[2]
154
154
+
155
155
+
record, err := xrpc.SlingshotClient.GetRecord(ctx, uri)
156
156
+
if err != nil {
157
157
+
return fetchSembleCardFromPDS(ctx, database, uri, did, parts[1], parts[2])
158
158
+
}
159
159
+
160
160
+
var card xrpc.SembleCard
161
161
+
if err := json.Unmarshal(record.Value, &card); err != nil {
162
162
+
return err
163
163
+
}
164
164
+
165
165
+
return indexSembleCard(database, uri, did, &card)
166
166
+
}
120
167
168
168
+
func fetchSembleCardFromPDS(ctx context.Context, database *db.DB, uri, did, collection, rkey string) error {
121
169
pds, err := xrpc.ResolveDIDToPDS(did)
122
170
if err != nil {
123
171
return fmt.Errorf("failed to resolve PDS: %w", err)
124
172
}
125
173
126
126
-
client := &http.Client{Timeout: 10 * time.Second}
127
127
-
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", pds, did, collection, rkey)
174
174
+
fetchURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", pds, did, collection, rkey)
128
175
129
129
-
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
176
176
+
req, err := http.NewRequestWithContext(ctx, "GET", fetchURL, nil)
130
177
if err != nil {
131
178
return err
132
179
}
133
180
181
181
+
client := &http.Client{Timeout: 5 * time.Second}
134
182
resp, err := client.Do(req)
135
183
if err != nil {
136
184
return fmt.Errorf("failed to fetch record: %w", err)
···
151
199
return err
152
200
}
153
201
202
202
+
return indexSembleCard(database, uri, did, &card)
203
203
+
}
204
204
+
205
205
+
func indexSembleCard(database *db.DB, uri, did string, card *xrpc.SembleCard) error {
154
206
createdAt := card.GetCreatedAtTime()
155
207
content, err := card.ParseContent()
156
208
if err != nil {
+58
-123
backend/internal/constellation/client.go
···
55
55
Cursor string `json:"cursor,omitempty"`
56
56
}
57
57
58
58
-
func (c *Client) GetLikeCount(ctx context.Context, subjectURI string) (int, error) {
58
58
+
func (c *Client) getBacklinksCount(ctx context.Context, subject, source string) (int, error) {
59
59
params := url.Values{}
60
60
-
params.Set("target", subjectURI)
61
61
-
params.Set("collection", "at.margin.like")
62
62
-
params.Set("path", ".subject.uri")
60
60
+
params.Set("subject", subject)
61
61
+
params.Set("source", source)
63
62
64
64
-
endpoint := fmt.Sprintf("%s/links/count/distinct-dids?%s", c.baseURL, params.Encode())
63
63
+
endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.links.getBacklinksCount?%s", c.baseURL, params.Encode())
65
64
66
65
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
67
66
if err != nil {
···
87
86
return countResp.Total, nil
88
87
}
89
88
90
90
-
func (c *Client) GetReplyCount(ctx context.Context, rootURI string) (int, error) {
89
89
+
type BacklinksResponse struct {
90
90
+
Backlinks []struct {
91
91
+
URI string `json:"uri"`
92
92
+
DID string `json:"did"`
93
93
+
} `json:"backlinks"`
94
94
+
Cursor string `json:"cursor,omitempty"`
95
95
+
}
96
96
+
97
97
+
func (c *Client) getBacklinks(ctx context.Context, subject, source string, limit int) (*BacklinksResponse, error) {
91
98
params := url.Values{}
92
92
-
params.Set("target", rootURI)
93
93
-
params.Set("collection", "at.margin.reply")
94
94
-
params.Set("path", ".root.uri")
99
99
+
params.Set("subject", subject)
100
100
+
params.Set("source", source)
101
101
+
if limit > 0 {
102
102
+
params.Set("limit", fmt.Sprintf("%d", limit))
103
103
+
}
95
104
96
96
-
endpoint := fmt.Sprintf("%s/links/count?%s", c.baseURL, params.Encode())
105
105
+
endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.links.getBacklinks?%s", c.baseURL, params.Encode())
97
106
98
107
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
99
108
if err != nil {
100
100
-
return 0, fmt.Errorf("failed to create request: %w", err)
109
109
+
return nil, fmt.Errorf("failed to create request: %w", err)
101
110
}
102
111
req.Header.Set("User-Agent", UserAgent)
103
112
104
113
resp, err := c.httpClient.Do(req)
105
114
if err != nil {
106
106
-
return 0, fmt.Errorf("request failed: %w", err)
115
115
+
return nil, fmt.Errorf("request failed: %w", err)
107
116
}
108
117
defer resp.Body.Close()
109
118
110
119
if resp.StatusCode != http.StatusOK {
111
111
-
return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
120
120
+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
112
121
}
113
122
114
114
-
var countResp CountResponse
115
115
-
if err := json.NewDecoder(resp.Body).Decode(&countResp); err != nil {
116
116
-
return 0, fmt.Errorf("failed to decode response: %w", err)
123
123
+
var result BacklinksResponse
124
124
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
125
125
+
return nil, fmt.Errorf("failed to decode response: %w", err)
117
126
}
118
127
119
119
-
return countResp.Total, nil
128
128
+
return &result, nil
129
129
+
}
130
130
+
131
131
+
func (c *Client) GetLikeCount(ctx context.Context, subjectURI string) (int, error) {
132
132
+
return c.getBacklinksCount(ctx, subjectURI, "at.margin.like:subject.uri")
133
133
+
}
134
134
+
135
135
+
func (c *Client) GetReplyCount(ctx context.Context, rootURI string) (int, error) {
136
136
+
return c.getBacklinksCount(ctx, rootURI, "at.margin.reply:root.uri")
120
137
}
121
138
122
139
type CountsResult struct {
···
159
176
}
160
177
161
178
func (c *Client) GetAnnotationsForURL(ctx context.Context, targetURL string) ([]Link, error) {
162
162
-
params := url.Values{}
163
163
-
params.Set("target", targetURL)
164
164
-
params.Set("collection", "at.margin.annotation")
165
165
-
params.Set("path", ".target.source")
166
166
-
167
167
-
endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode())
168
168
-
169
169
-
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
170
170
-
if err != nil {
171
171
-
return nil, fmt.Errorf("failed to create request: %w", err)
172
172
-
}
173
173
-
req.Header.Set("User-Agent", UserAgent)
174
174
-
175
175
-
resp, err := c.httpClient.Do(req)
179
179
+
resp, err := c.getBacklinks(ctx, targetURL, "at.margin.annotation:target.source", 100)
176
180
if err != nil {
177
177
-
return nil, fmt.Errorf("request failed: %w", err)
178
178
-
}
179
179
-
defer resp.Body.Close()
180
180
-
181
181
-
if resp.StatusCode != http.StatusOK {
182
182
-
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
181
181
+
return nil, err
183
182
}
184
184
-
185
185
-
var linksResp LinksResponse
186
186
-
if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil {
187
187
-
return nil, fmt.Errorf("failed to decode response: %w", err)
183
183
+
links := make([]Link, len(resp.Backlinks))
184
184
+
for i, bl := range resp.Backlinks {
185
185
+
links[i] = Link{URI: bl.URI, DID: bl.DID, Collection: "at.margin.annotation", Path: ".target.source"}
188
186
}
189
189
-
190
190
-
return linksResp.Links, nil
187
187
+
return links, nil
191
188
}
192
189
193
190
func (c *Client) GetHighlightsForURL(ctx context.Context, targetURL string) ([]Link, error) {
194
194
-
params := url.Values{}
195
195
-
params.Set("target", targetURL)
196
196
-
params.Set("collection", "at.margin.highlight")
197
197
-
params.Set("path", ".target.source")
198
198
-
199
199
-
endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode())
200
200
-
201
201
-
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
191
191
+
resp, err := c.getBacklinks(ctx, targetURL, "at.margin.highlight:target.source", 100)
202
192
if err != nil {
203
203
-
return nil, fmt.Errorf("failed to create request: %w", err)
193
193
+
return nil, err
204
194
}
205
205
-
req.Header.Set("User-Agent", UserAgent)
206
206
-
207
207
-
resp, err := c.httpClient.Do(req)
208
208
-
if err != nil {
209
209
-
return nil, fmt.Errorf("request failed: %w", err)
195
195
+
links := make([]Link, len(resp.Backlinks))
196
196
+
for i, bl := range resp.Backlinks {
197
197
+
links[i] = Link{URI: bl.URI, DID: bl.DID, Collection: "at.margin.highlight", Path: ".target.source"}
210
198
}
211
211
-
defer resp.Body.Close()
212
212
-
213
213
-
if resp.StatusCode != http.StatusOK {
214
214
-
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
215
215
-
}
216
216
-
217
217
-
var linksResp LinksResponse
218
218
-
if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil {
219
219
-
return nil, fmt.Errorf("failed to decode response: %w", err)
220
220
-
}
221
221
-
222
222
-
return linksResp.Links, nil
199
199
+
return links, nil
223
200
}
224
201
225
202
func (c *Client) GetBookmarksForURL(ctx context.Context, targetURL string) ([]Link, error) {
226
226
-
params := url.Values{}
227
227
-
params.Set("target", targetURL)
228
228
-
params.Set("collection", "at.margin.bookmark")
229
229
-
params.Set("path", ".source")
230
230
-
231
231
-
endpoint := fmt.Sprintf("%s/links?%s", c.baseURL, params.Encode())
232
232
-
233
233
-
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
234
234
-
if err != nil {
235
235
-
return nil, fmt.Errorf("failed to create request: %w", err)
236
236
-
}
237
237
-
req.Header.Set("User-Agent", UserAgent)
238
238
-
239
239
-
resp, err := c.httpClient.Do(req)
203
203
+
resp, err := c.getBacklinks(ctx, targetURL, "at.margin.bookmark:source", 100)
240
204
if err != nil {
241
241
-
return nil, fmt.Errorf("request failed: %w", err)
242
242
-
}
243
243
-
defer resp.Body.Close()
244
244
-
245
245
-
if resp.StatusCode != http.StatusOK {
246
246
-
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
205
205
+
return nil, err
247
206
}
248
248
-
249
249
-
var linksResp LinksResponse
250
250
-
if err := json.NewDecoder(resp.Body).Decode(&linksResp); err != nil {
251
251
-
return nil, fmt.Errorf("failed to decode response: %w", err)
207
207
+
links := make([]Link, len(resp.Backlinks))
208
208
+
for i, bl := range resp.Backlinks {
209
209
+
links[i] = Link{URI: bl.URI, DID: bl.DID, Collection: "at.margin.bookmark", Path: ".source"}
252
210
}
253
253
-
254
254
-
return linksResp.Links, nil
211
211
+
return links, nil
255
212
}
256
213
257
214
func (c *Client) GetAllItemsForURL(ctx context.Context, targetURL string) (annotations, highlights, bookmarks []Link, err error) {
···
307
264
}
308
265
309
266
func (c *Client) GetLikers(ctx context.Context, subjectURI string) ([]string, error) {
310
310
-
params := url.Values{}
311
311
-
params.Set("target", subjectURI)
312
312
-
params.Set("collection", "at.margin.like")
313
313
-
params.Set("path", ".subject.uri")
314
314
-
315
315
-
endpoint := fmt.Sprintf("%s/links/distinct-dids?%s", c.baseURL, params.Encode())
316
316
-
317
317
-
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
267
267
+
resp, err := c.getBacklinks(ctx, subjectURI, "at.margin.like:subject.uri", 100)
318
268
if err != nil {
319
319
-
return nil, fmt.Errorf("failed to create request: %w", err)
320
320
-
}
321
321
-
req.Header.Set("User-Agent", UserAgent)
322
322
-
323
323
-
resp, err := c.httpClient.Do(req)
324
324
-
if err != nil {
325
325
-
return nil, fmt.Errorf("request failed: %w", err)
326
326
-
}
327
327
-
defer resp.Body.Close()
328
328
-
329
329
-
if resp.StatusCode != http.StatusOK {
330
330
-
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
269
269
+
return nil, err
331
270
}
332
332
-
333
333
-
var result struct {
334
334
-
DIDs []string `json:"dids"`
271
271
+
dids := make([]string, len(resp.Backlinks))
272
272
+
for i, bl := range resp.Backlinks {
273
273
+
dids[i] = bl.DID
335
274
}
336
336
-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
337
337
-
return nil, fmt.Errorf("failed to decode response: %w", err)
338
338
-
}
339
339
-
340
340
-
return result.DIDs, nil
275
275
+
return dids, nil
341
276
}
+4
-4
backend/internal/firehose/ingester.go
···
249
249
i.dispatchToHandler(firehoseEvent)
250
250
251
251
did := event.Did
252
252
-
select {
253
253
-
case i.workerPool <- func() { i.triggerLazySync(did) }:
254
254
-
default:
255
255
-
}
252
252
+
select {
253
253
+
case i.workerPool <- func() { i.triggerLazySync(did) }:
254
254
+
default:
255
255
+
}
256
256
}
257
257
case "delete":
258
258
i.handleDelete(commit.Collection, uri)
+6
-3
backend/internal/slingshot/client.go
···
51
51
}
52
52
53
53
func (c *Client) ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) {
54
54
-
endpoint := fmt.Sprintf("%s/identity/%s", c.baseURL, url.PathEscape(identifier))
54
54
+
params := url.Values{}
55
55
+
params.Set("identifier", identifier)
56
56
+
57
57
+
endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.identity.resolveMiniDoc?%s", c.baseURL, params.Encode())
55
58
56
59
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
57
60
if err != nil {
···
83
86
84
87
func (c *Client) GetRecord(ctx context.Context, uri string) (*Record, error) {
85
88
params := url.Values{}
86
86
-
params.Set("uri", uri)
89
89
+
params.Set("at_uri", uri)
87
90
88
88
-
endpoint := fmt.Sprintf("%s/record?%s", c.baseURL, params.Encode())
91
91
+
endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.repo.getRecordByUri?%s", c.baseURL, params.Encode())
89
92
90
93
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
91
94
if err != nil {
+158
-27
backend/internal/verification/verify.go
···
7
7
"net/url"
8
8
"regexp"
9
9
"strings"
10
10
+
"sync"
10
11
"time"
11
12
12
13
"margin.at/internal/logger"
13
14
)
14
15
15
16
var client = &http.Client{
16
16
-
Timeout: 10 * time.Second,
17
17
+
Timeout: 5 * time.Second,
17
18
CheckRedirect: func(req *http.Request, via []*http.Request) error {
18
19
if len(via) >= 3 {
19
20
return fmt.Errorf("too many redirects")
···
22
23
},
23
24
}
24
25
25
25
-
var verifySem = make(chan struct{}, 3)
26
26
+
var linkTagPattern = regexp.MustCompile(`<link[^>]+rel=["']site\.standard\.document["'][^>]+href=["']([^"']+)["'][^>]*/?>|<link[^>]+href=["']([^"']+)["'][^>]+rel=["']site\.standard\.document["'][^>]*/?>`)
27
27
+
28
28
+
var (
29
29
+
verifyQueue = make(chan verifyTask, 50)
30
30
+
recentMu sync.RWMutex
31
31
+
recentURIs = make(map[string]time.Time)
32
32
+
)
33
33
+
34
34
+
var (
35
35
+
domainMu sync.Mutex
36
36
+
domainActive = make(map[string]int)
37
37
+
domainMaxConc = 1
38
38
+
)
39
39
+
40
40
+
var rateLimiter = make(chan struct{}, 2)
41
41
+
42
42
+
func init() {
43
43
+
for i := 0; i < cap(rateLimiter); i++ {
44
44
+
rateLimiter <- struct{}{}
45
45
+
}
46
46
+
go func() {
47
47
+
ticker := time.NewTicker(500 * time.Millisecond)
48
48
+
for range ticker.C {
49
49
+
select {
50
50
+
case rateLimiter <- struct{}{}:
51
51
+
default:
52
52
+
}
53
53
+
}
54
54
+
}()
55
55
+
56
56
+
for i := 0; i < 3; i++ {
57
57
+
go verifyWorker()
58
58
+
}
59
59
+
go func() {
60
60
+
for {
61
61
+
time.Sleep(5 * time.Minute)
62
62
+
recentMu.Lock()
63
63
+
cutoff := time.Now().Add(-10 * time.Minute)
64
64
+
for uri, t := range recentURIs {
65
65
+
if t.Before(cutoff) {
66
66
+
delete(recentURIs, uri)
67
67
+
}
68
68
+
}
69
69
+
recentMu.Unlock()
70
70
+
71
71
+
domainMu.Lock()
72
72
+
for d, c := range domainActive {
73
73
+
if c <= 0 {
74
74
+
delete(domainActive, d)
75
75
+
}
76
76
+
}
77
77
+
domainMu.Unlock()
78
78
+
}
79
79
+
}()
80
80
+
}
81
81
+
82
82
+
type verifyTask struct {
83
83
+
url string
84
84
+
uri string
85
85
+
onVerified func(string)
86
86
+
isDoc bool
87
87
+
}
88
88
+
89
89
+
func extractDomain(rawURL string) string {
90
90
+
parsed, err := url.Parse(rawURL)
91
91
+
if err != nil {
92
92
+
return ""
93
93
+
}
94
94
+
return parsed.Host
95
95
+
}
96
96
+
97
97
+
func acquireDomain(domain string) bool {
98
98
+
if domain == "" {
99
99
+
return true
100
100
+
}
101
101
+
domainMu.Lock()
102
102
+
defer domainMu.Unlock()
103
103
+
if domainActive[domain] >= domainMaxConc {
104
104
+
return false
105
105
+
}
106
106
+
domainActive[domain]++
107
107
+
return true
108
108
+
}
109
109
+
110
110
+
func releaseDomain(domain string) {
111
111
+
if domain == "" {
112
112
+
return
113
113
+
}
114
114
+
domainMu.Lock()
115
115
+
domainActive[domain]--
116
116
+
if domainActive[domain] <= 0 {
117
117
+
delete(domainActive, domain)
118
118
+
}
119
119
+
domainMu.Unlock()
120
120
+
}
121
121
+
122
122
+
func verifyWorker() {
123
123
+
for task := range verifyQueue {
124
124
+
<-rateLimiter
26
125
27
27
-
var linkTagPattern = regexp.MustCompile(`<link[^>]+rel=["']site\.standard\.document["'][^>]+href=["']([^"']+)["'][^>]*/?>|<link[^>]+href=["']([^"']+)["'][^>]+rel=["']site\.standard\.document["'][^>]*/?>`)
126
126
+
domain := extractDomain(task.url)
127
127
+
128
128
+
if !acquireDomain(domain) {
129
129
+
continue
130
130
+
}
131
131
+
132
132
+
var err error
133
133
+
if task.isDoc {
134
134
+
err = VerifyDocument(task.url, task.uri)
135
135
+
} else {
136
136
+
err = VerifyPublication(task.url, task.uri)
137
137
+
}
138
138
+
139
139
+
releaseDomain(domain)
140
140
+
141
141
+
if err != nil {
142
142
+
continue
143
143
+
}
144
144
+
kind := "Publication"
145
145
+
if task.isDoc {
146
146
+
kind = "Document"
147
147
+
}
148
148
+
logger.Info("%s verified: %s", kind, task.uri)
149
149
+
if task.onVerified != nil {
150
150
+
task.onVerified(task.uri)
151
151
+
}
152
152
+
}
153
153
+
}
154
154
+
155
155
+
func isDuplicate(uri string) bool {
156
156
+
recentMu.RLock()
157
157
+
_, exists := recentURIs[uri]
158
158
+
recentMu.RUnlock()
159
159
+
if exists {
160
160
+
return true
161
161
+
}
162
162
+
recentMu.Lock()
163
163
+
recentURIs[uri] = time.Now()
164
164
+
recentMu.Unlock()
165
165
+
return false
166
166
+
}
28
167
29
168
func VerifyPublication(pubURL, expectedURI string) error {
30
169
pubURL = strings.TrimRight(pubURL, "/")
···
108
247
}
109
248
110
249
func VerifyPublicationAsync(pubURL, uri string, onVerified func(string)) {
111
111
-
go func() {
112
112
-
verifySem <- struct{}{}
113
113
-
defer func() { <-verifySem }()
114
114
-
115
115
-
if err := VerifyPublication(pubURL, uri); err != nil {
116
116
-
return
117
117
-
}
118
118
-
logger.Info("Publication verified: %s", uri)
119
119
-
if onVerified != nil {
120
120
-
onVerified(uri)
121
121
-
}
122
122
-
}()
250
250
+
if isDuplicate(uri) {
251
251
+
return
252
252
+
}
253
253
+
select {
254
254
+
case verifyQueue <- verifyTask{url: pubURL, uri: uri, onVerified: onVerified, isDoc: false}:
255
255
+
default:
256
256
+
// Queue full — drop silently to protect network
257
257
+
}
123
258
}
124
259
125
260
func VerifyDocumentAsync(docURL, uri string, onVerified func(string)) {
126
126
-
go func() {
127
127
-
verifySem <- struct{}{}
128
128
-
defer func() { <-verifySem }()
129
129
-
130
130
-
if err := VerifyDocument(docURL, uri); err != nil {
131
131
-
return
132
132
-
}
133
133
-
logger.Info("Document verified: %s", uri)
134
134
-
if onVerified != nil {
135
135
-
onVerified(uri)
136
136
-
}
137
137
-
}()
261
261
+
if isDuplicate(uri) {
262
262
+
return
263
263
+
}
264
264
+
select {
265
265
+
case verifyQueue <- verifyTask{url: docURL, uri: uri, onVerified: onVerified, isDoc: true}:
266
266
+
default:
267
267
+
// Queue full — drop silently to protect network
268
268
+
}
138
269
}
+74
-81
web/src/components/common/Card.tsx
···
186
186
187
187
React.useEffect(() => {
188
188
if (isBookmark && item.uri && !ogData && pageUrl) {
189
189
-
const fetchMetadata = async () => {
190
190
-
try {
191
191
-
const res = await fetch(
192
192
-
`/api/url-metadata?url=${encodeURIComponent(pageUrl)}`,
193
193
-
);
194
194
-
if (res.ok) {
195
195
-
const data = await res.json();
196
196
-
setOgData(data);
197
197
-
try {
198
198
-
sessionStorage.setItem(`og:${pageUrl}`, JSON.stringify(data));
199
199
-
} catch {
200
200
-
/* quota exceeded */
201
201
-
}
202
202
-
}
203
203
-
} catch (e) {
204
204
-
console.error("Failed to fetch metadata", e);
205
205
-
}
189
189
+
let cancelled = false;
190
190
+
import("../../lib/metadataQueue").then(({ fetchMetadata }) => {
191
191
+
fetchMetadata(pageUrl).then((data) => {
192
192
+
if (!cancelled && data) setOgData(data);
193
193
+
});
194
194
+
});
195
195
+
return () => {
196
196
+
cancelled = true;
206
197
};
207
207
-
fetchMetadata();
208
198
}
209
199
}, [isBookmark, item.uri, pageUrl, ogData]);
210
200
···
344
334
const displayImage = ogData?.image;
345
335
346
336
return (
347
347
-
<article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative">
337
337
+
<article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative overflow-hidden">
348
338
{(item.collection || (item.context && item.context.length > 0)) && (
349
339
<div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap">
350
340
{item.addedBy && item.addedBy.did !== item.author?.did ? (
···
513
503
)}
514
504
>
515
505
{contentWarning && !contentRevealed && (
516
516
-
<div className="absolute inset-0 z-10 rounded-lg bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center gap-2 py-4">
506
506
+
<div className="z-10 rounded-lg bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center gap-2 py-6 min-h-[120px]">
517
507
<div className="flex items-center gap-2 text-surface-500 dark:text-surface-400">
518
508
<EyeOff size={16} />
519
509
<span className="text-sm font-medium">
···
538
528
Hide Content
539
529
</button>
540
530
)}
541
541
-
{isBookmark && (
531
531
+
{!(contentWarning && !contentRevealed) && isBookmark && (
542
532
<div
543
533
onClick={(e) => {
544
534
e.preventDefault();
···
609
599
</div>
610
600
)}
611
601
612
612
-
{item.target?.selector?.exact && (
613
613
-
<blockquote
614
614
-
className={clsx(
615
615
-
"pl-4 py-2 border-l-[3px] mb-3 text-[15px] italic text-surface-600 dark:text-surface-300 rounded-r-lg hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors",
616
616
-
!item.color &&
617
617
-
type === "highlight" &&
618
618
-
"border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20",
619
619
-
item.color === "yellow" &&
620
620
-
"border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20",
621
621
-
item.color === "green" &&
622
622
-
"border-green-400 bg-green-50/50 dark:bg-green-900/20",
623
623
-
item.color === "red" &&
624
624
-
"border-red-400 bg-red-50/50 dark:bg-red-900/20",
625
625
-
item.color === "blue" &&
626
626
-
"border-blue-400 bg-blue-50/50 dark:bg-blue-900/20",
627
627
-
!item.color &&
628
628
-
type !== "highlight" &&
629
629
-
"border-surface-300 dark:border-surface-600",
630
630
-
)}
631
631
-
style={
632
632
-
item.color?.startsWith("#")
633
633
-
? {
634
634
-
borderColor: item.color,
635
635
-
backgroundColor: `${item.color}15`,
636
636
-
}
637
637
-
: undefined
638
638
-
}
639
639
-
>
640
640
-
<a
641
641
-
href={`${pageUrl}#:~:text=${item.target.selector.prefix ? encodeURIComponent(item.target.selector.prefix) + "-," : ""}${encodeURIComponent(item.target.selector.exact)}${item.target.selector.suffix ? ",-" + encodeURIComponent(item.target.selector.suffix) : ""}`}
642
642
-
target="_blank"
643
643
-
rel="noopener noreferrer"
644
644
-
onClick={(e) => {
645
645
-
const sel = item.target?.selector;
646
646
-
if (!sel) return;
647
647
-
const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`;
648
648
-
handleExternalClick(e, url);
649
649
-
}}
650
650
-
className="block"
602
602
+
{!(contentWarning && !contentRevealed) &&
603
603
+
item.target?.selector?.exact && (
604
604
+
<blockquote
605
605
+
className={clsx(
606
606
+
"pl-4 py-2 border-l-[3px] mb-3 text-[15px] italic text-surface-600 dark:text-surface-300 rounded-r-lg hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors",
607
607
+
!item.color &&
608
608
+
type === "highlight" &&
609
609
+
"border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20",
610
610
+
item.color === "yellow" &&
611
611
+
"border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20",
612
612
+
item.color === "green" &&
613
613
+
"border-green-400 bg-green-50/50 dark:bg-green-900/20",
614
614
+
item.color === "red" &&
615
615
+
"border-red-400 bg-red-50/50 dark:bg-red-900/20",
616
616
+
item.color === "blue" &&
617
617
+
"border-blue-400 bg-blue-50/50 dark:bg-blue-900/20",
618
618
+
!item.color &&
619
619
+
type !== "highlight" &&
620
620
+
"border-surface-300 dark:border-surface-600",
621
621
+
)}
622
622
+
style={
623
623
+
item.color?.startsWith("#")
624
624
+
? {
625
625
+
borderColor: item.color,
626
626
+
backgroundColor: `${item.color}15`,
627
627
+
}
628
628
+
: undefined
629
629
+
}
651
630
>
652
652
-
"{item.target?.selector?.exact}"
653
653
-
</a>
654
654
-
</blockquote>
655
655
-
)}
631
631
+
<a
632
632
+
href={`${pageUrl}#:~:text=${item.target.selector.prefix ? encodeURIComponent(item.target.selector.prefix) + "-," : ""}${encodeURIComponent(item.target.selector.exact)}${item.target.selector.suffix ? ",-" + encodeURIComponent(item.target.selector.suffix) : ""}`}
633
633
+
target="_blank"
634
634
+
rel="noopener noreferrer"
635
635
+
onClick={(e) => {
636
636
+
const sel = item.target?.selector;
637
637
+
if (!sel) return;
638
638
+
const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`;
639
639
+
handleExternalClick(e, url);
640
640
+
}}
641
641
+
className="block"
642
642
+
>
643
643
+
"{item.target?.selector?.exact}"
644
644
+
</a>
645
645
+
</blockquote>
646
646
+
)}
656
647
657
657
-
{item.body?.value && (
658
658
-
<p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]">
648
648
+
{!(contentWarning && !contentRevealed) && item.body?.value && (
649
649
+
<p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap break-words leading-relaxed text-[15px]">
659
650
<RichText text={item.body.value} />
660
651
</p>
661
652
)}
662
653
663
663
-
{item.tags && item.tags.length > 0 && (
664
664
-
<div className="flex flex-wrap gap-2 mt-3">
665
665
-
{item.tags.map((tag) => (
666
666
-
<a
667
667
-
key={tag}
668
668
-
href={`/home?tag=${encodeURIComponent(tag)}`}
669
669
-
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-surface-100 dark:bg-surface-800 text-xs font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
670
670
-
onClick={(e) => e.stopPropagation()}
671
671
-
>
672
672
-
<Tag size={10} />
673
673
-
<span>{tag}</span>
674
674
-
</a>
675
675
-
))}
676
676
-
</div>
677
677
-
)}
654
654
+
{!(contentWarning && !contentRevealed) &&
655
655
+
item.tags &&
656
656
+
item.tags.length > 0 && (
657
657
+
<div className="flex flex-wrap gap-2 mt-3">
658
658
+
{item.tags.map((tag) => (
659
659
+
<a
660
660
+
key={tag}
661
661
+
href={`/home?tag=${encodeURIComponent(tag)}`}
662
662
+
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-surface-100 dark:bg-surface-800 text-xs font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
663
663
+
onClick={(e) => e.stopPropagation()}
664
664
+
>
665
665
+
<Tag size={10} />
666
666
+
<span>{tag}</span>
667
667
+
</a>
668
668
+
))}
669
669
+
</div>
670
670
+
)}
678
671
</div>
679
672
680
673
<div className="flex items-center gap-1 mt-3 ml-[52px] md:ml-0 md:gap-0">
+15
-5
web/src/components/feed/FeedItems.tsx
···
1
1
import { Clock, Loader2 } from "lucide-react";
2
2
-
import { useCallback, useEffect, useState } from "react";
2
2
+
import { useCallback, useEffect, useRef, useState } from "react";
3
3
import { type GetFeedParams, getFeed } from "../../api/client";
4
4
import Card from "../../components/common/Card";
5
5
import { EmptyState } from "../../components/ui";
···
23
23
> {
24
24
layout: "list" | "mosaic";
25
25
emptyMessage: string;
26
26
+
initialItems?: AnnotationItem[];
27
27
+
initialHasMore?: boolean;
26
28
}
27
29
28
30
export default function FeedItems({
···
33
35
motivation,
34
36
emptyMessage,
35
37
layout,
38
38
+
initialItems,
39
39
+
initialHasMore,
36
40
}: FeedItemsProps) {
37
37
-
const [items, setItems] = useState<AnnotationItem[]>([]);
38
38
-
const [loading, setLoading] = useState(true);
41
41
+
const [items, setItems] = useState<AnnotationItem[]>(initialItems || []);
42
42
+
const [loading, setLoading] = useState(!initialItems);
39
43
const [loadingMore, setLoadingMore] = useState(false);
40
40
-
const [hasMore, setHasMore] = useState(false);
41
41
-
const [offset, setOffset] = useState(0);
44
44
+
const [hasMore, setHasMore] = useState(initialHasMore ?? false);
45
45
+
const [offset, setOffset] = useState(initialItems?.length ?? 0);
46
46
+
const skipInitialFetch = useRef(!!initialItems);
42
47
43
48
useEffect(() => {
49
49
+
if (skipInitialFetch.current) {
50
50
+
skipInitialFetch.current = false;
51
51
+
return;
52
52
+
}
53
53
+
44
54
let cancelled = false;
45
55
const cacheKey = JSON.stringify({ type, motivation, tag, creator, source });
46
56
const cached = feedCache.get(cacheKey);
+32
-18
web/src/components/modals/AddToCollectionModal.tsx
···
9
9
} from "lucide-react";
10
10
import CollectionIcon from "../common/CollectionIcon";
11
11
import { ICON_MAP } from "../common/iconMap";
12
12
-
import EmojiPicker, { Theme } from "emoji-picker-react";
12
12
+
import { Theme } from "emoji-picker-react";
13
13
+
const EmojiPicker = React.lazy(() => import("emoji-picker-react"));
13
14
import { useStore } from "@nanostores/react";
14
15
import { $user } from "../../store/auth";
15
16
import { $theme } from "../../store/theme";
···
241
242
</div>
242
243
) : (
243
244
<div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
244
244
-
<EmojiPicker
245
245
-
className="custom-emoji-picker"
246
246
-
onEmojiClick={(emojiData) => setNewIcon(emojiData.emoji)}
247
247
-
autoFocusSearch={false}
248
248
-
width="100%"
249
249
-
height={300}
250
250
-
previewConfig={{ showPreview: false }}
251
251
-
skinTonesDisabled
252
252
-
lazyLoadEmojis
253
253
-
theme={
254
254
-
theme === "dark" ||
255
255
-
(theme === "system" &&
256
256
-
window.matchMedia("(prefers-color-scheme: dark)")
257
257
-
.matches)
258
258
-
? (Theme.DARK as Theme)
259
259
-
: (Theme.LIGHT as Theme)
245
245
+
<React.Suspense
246
246
+
fallback={
247
247
+
<div className="flex items-center justify-center h-[300px]">
248
248
+
<Loader2
249
249
+
className="animate-spin text-surface-400"
250
250
+
size={24}
251
251
+
/>
252
252
+
</div>
260
253
}
261
261
-
/>
254
254
+
>
255
255
+
<EmojiPicker
256
256
+
className="custom-emoji-picker"
257
257
+
onEmojiClick={(emojiData) =>
258
258
+
setNewIcon(emojiData.emoji)
259
259
+
}
260
260
+
autoFocusSearch={false}
261
261
+
width="100%"
262
262
+
height={300}
263
263
+
previewConfig={{ showPreview: false }}
264
264
+
skinTonesDisabled
265
265
+
lazyLoadEmojis
266
266
+
theme={
267
267
+
theme === "dark" ||
268
268
+
(theme === "system" &&
269
269
+
window.matchMedia("(prefers-color-scheme: dark)")
270
270
+
.matches)
271
271
+
? (Theme.DARK as Theme)
272
272
+
: (Theme.LIGHT as Theme)
273
273
+
}
274
274
+
/>
275
275
+
</React.Suspense>
262
276
</div>
263
277
)}
264
278
+30
-18
web/src/components/modals/EditCollectionModal.tsx
···
2
2
import { X, Loader2 } from "lucide-react";
3
3
import CollectionIcon from "../common/CollectionIcon";
4
4
import { ICON_MAP } from "../common/iconMap";
5
5
-
import EmojiPicker, { Theme } from "emoji-picker-react";
5
5
+
import { Theme } from "emoji-picker-react";
6
6
+
const EmojiPicker = React.lazy(() => import("emoji-picker-react"));
6
7
import { updateCollection, type Collection } from "../../api/client";
7
8
import { useStore } from "@nanostores/react";
8
9
import { $theme } from "../../store/theme";
···
188
189
</div>
189
190
) : (
190
191
<div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
191
191
-
<EmojiPicker
192
192
-
className="custom-emoji-picker"
193
193
-
onEmojiClick={(emojiData) => setIcon(emojiData.emoji)}
194
194
-
autoFocusSearch={false}
195
195
-
width="100%"
196
196
-
height={300}
197
197
-
previewConfig={{ showPreview: false }}
198
198
-
skinTonesDisabled
199
199
-
lazyLoadEmojis
200
200
-
theme={
201
201
-
theme === "dark" ||
202
202
-
(theme === "system" &&
203
203
-
window.matchMedia("(prefers-color-scheme: dark)")
204
204
-
.matches)
205
205
-
? (Theme.DARK as Theme)
206
206
-
: (Theme.LIGHT as Theme)
192
192
+
<React.Suspense
193
193
+
fallback={
194
194
+
<div className="flex items-center justify-center h-[300px]">
195
195
+
<Loader2
196
196
+
className="animate-spin text-surface-400"
197
197
+
size={24}
198
198
+
/>
199
199
+
</div>
207
200
}
208
208
-
/>
201
201
+
>
202
202
+
<EmojiPicker
203
203
+
className="custom-emoji-picker"
204
204
+
onEmojiClick={(emojiData) => setIcon(emojiData.emoji)}
205
205
+
autoFocusSearch={false}
206
206
+
width="100%"
207
207
+
height={300}
208
208
+
previewConfig={{ showPreview: false }}
209
209
+
skinTonesDisabled
210
210
+
lazyLoadEmojis
211
211
+
theme={
212
212
+
theme === "dark" ||
213
213
+
(theme === "system" &&
214
214
+
window.matchMedia("(prefers-color-scheme: dark)")
215
215
+
.matches)
216
216
+
? (Theme.DARK as Theme)
217
217
+
: (Theme.LIGHT as Theme)
218
218
+
}
219
219
+
/>
220
220
+
</React.Suspense>
209
221
</div>
210
222
)}
211
223
+6
-4
web/src/components/modals/ShareMenu.tsx
···
52
52
if (customUrl) return customUrl;
53
53
if (!uri) return "";
54
54
55
55
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
55
56
const uriParts = uri.split("/");
56
57
const rkey = uriParts[uriParts.length - 1];
57
58
const did = uriParts[2];
58
59
59
60
if (uri.includes("network.cosmik.card"))
60
60
-
return `${window.location.origin}/at/${did}/${rkey}`;
61
61
+
return `${origin}/at/${did}/${rkey}`;
61
62
if (handle && type)
62
62
-
return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`;
63
63
-
return `${window.location.origin}/at/${did}/${rkey}`;
63
63
+
return `${origin}/${handle}/${type.toLowerCase()}/${rkey}`;
64
64
+
return `${origin}/at/${did}/${rkey}`;
64
65
};
65
66
66
67
const shareUrl = getShareUrl();
···
277
278
copied === "aturi",
278
279
)}
279
280
280
280
-
{navigator.share &&
281
281
+
{typeof navigator !== "undefined" &&
282
282
+
navigator.share &&
281
283
renderMenuItem(
282
284
"More Options...",
283
285
<MoreHorizontal size={16} />,
+20
-7
web/src/pages/[handle]/annotation/[rkey].astro
···
3
3
import AppLayout from '../../../layouts/AppLayout.astro';
4
4
import AnnotationDetail from '../../../views/content/AnnotationDetail';
5
5
import { resolveHandle, fetchOGForRoute } from '../../../lib/og';
6
6
+
import { getAnnotation, getReplies } from '../../../lib/api';
6
7
7
8
const { handle, rkey } = Astro.params;
8
9
const user = Astro.locals.user;
10
10
+
const cookie = Astro.request.headers.get('cookie') || '';
9
11
10
12
let title = 'Annotation - Margin';
11
13
let description = 'Annotate the web';
12
14
let image = 'https://margin.at/og.png';
15
15
+
let initialAnnotation = null;
16
16
+
let initialReplies: any[] = [];
17
17
+
let resolvedUri = '';
13
18
14
19
if (handle && rkey) {
15
20
try {
16
21
const did = await resolveHandle(handle);
17
22
if (did) {
18
18
-
const data = await fetchOGForRoute(did, rkey, 'at.margin.annotation');
19
19
-
if (data) {
20
20
-
title = data.title;
21
21
-
description = data.description;
22
22
-
image = data.image;
23
23
+
resolvedUri = `at://${did}/at.margin.annotation/${rkey}`;
24
24
+
const [ogData, annData, repData] = await Promise.all([
25
25
+
fetchOGForRoute(did, rkey, 'at.margin.annotation'),
26
26
+
getAnnotation(cookie, resolvedUri),
27
27
+
getReplies(cookie, resolvedUri),
28
28
+
]);
29
29
+
if (ogData) {
30
30
+
title = ogData.title;
31
31
+
description = ogData.description;
32
32
+
image = ogData.image;
23
33
}
34
34
+
initialAnnotation = annData;
35
35
+
initialReplies = repData;
24
36
}
25
37
} catch (e) {
26
26
-
console.error('OG fetch error (annotation):', e);
38
38
+
console.error('OG/data fetch error (annotation):', e);
27
39
}
28
40
}
29
41
---
30
42
31
43
<AppLayout title={title} description={description} image={image} user={user}>
32
32
-
<AnnotationDetail client:load handle={handle} rkey={rkey} type="annotation" />
44
44
+
<AnnotationDetail client:idle handle={handle} rkey={rkey} type="annotation"
45
45
+
initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} />
33
46
</AppLayout>
+20
-7
web/src/pages/[handle]/bookmark/[rkey].astro
···
3
3
import AppLayout from '../../../layouts/AppLayout.astro';
4
4
import AnnotationDetail from '../../../views/content/AnnotationDetail';
5
5
import { resolveHandle, fetchOGForRoute } from '../../../lib/og';
6
6
+
import { getAnnotation, getReplies } from '../../../lib/api';
6
7
7
8
const { handle, rkey } = Astro.params;
8
9
const user = Astro.locals.user;
10
10
+
const cookie = Astro.request.headers.get('cookie') || '';
9
11
10
12
let title = 'Bookmark - Margin';
11
13
let description = 'Annotate the web';
12
14
let image = 'https://margin.at/og.png';
15
15
+
let initialAnnotation = null;
16
16
+
let initialReplies: any[] = [];
17
17
+
let resolvedUri = '';
13
18
14
19
if (handle && rkey) {
15
20
try {
16
21
const did = await resolveHandle(handle);
17
22
if (did) {
18
18
-
const data = await fetchOGForRoute(did, rkey, 'at.margin.bookmark');
19
19
-
if (data) {
20
20
-
title = data.title;
21
21
-
description = data.description;
22
22
-
image = data.image;
23
23
+
resolvedUri = `at://${did}/at.margin.bookmark/${rkey}`;
24
24
+
const [ogData, annData, repData] = await Promise.all([
25
25
+
fetchOGForRoute(did, rkey, 'at.margin.bookmark'),
26
26
+
getAnnotation(cookie, resolvedUri),
27
27
+
getReplies(cookie, resolvedUri),
28
28
+
]);
29
29
+
if (ogData) {
30
30
+
title = ogData.title;
31
31
+
description = ogData.description;
32
32
+
image = ogData.image;
23
33
}
34
34
+
initialAnnotation = annData;
35
35
+
initialReplies = repData;
24
36
}
25
37
} catch (e) {
26
26
-
console.error('OG fetch error (bookmark):', e);
38
38
+
console.error('OG/data fetch error (bookmark):', e);
27
39
}
28
40
}
29
41
---
30
42
31
43
<AppLayout title={title} description={description} image={image} user={user}>
32
32
-
<AnnotationDetail client:load handle={handle} rkey={rkey} type="bookmark" />
44
44
+
<AnnotationDetail client:idle handle={handle} rkey={rkey} type="bookmark"
45
45
+
initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} />
33
46
</AppLayout>
+21
-8
web/src/pages/[handle]/collection/[rkey].astro
···
3
3
import AppLayout from '../../../layouts/AppLayout.astro';
4
4
import CollectionDetail from '../../../views/collections/CollectionDetail';
5
5
import { resolveHandle, fetchCollectionOG } from '../../../lib/og';
6
6
+
import { getCollection, getCollectionItems } from '../../../lib/api';
6
7
7
8
const { handle, rkey } = Astro.params;
8
9
const user = Astro.locals.user;
10
10
+
const cookie = Astro.request.headers.get('cookie') || '';
9
11
10
12
let title = 'Collection - Margin';
11
13
let description = 'Annotate the web';
12
14
let image = 'https://margin.at/og.png';
15
15
+
let initialCollection = null;
16
16
+
let initialItems: any[] = [];
17
17
+
let resolvedUri = '';
13
18
14
19
if (handle && rkey) {
15
20
try {
16
21
const did = await resolveHandle(handle);
17
22
if (did) {
18
18
-
const uri = `at://${did}/at.margin.collection/${rkey}`;
19
19
-
const data = await fetchCollectionOG(uri);
20
20
-
if (data) {
21
21
-
title = data.title;
22
22
-
description = data.description;
23
23
-
image = data.image;
23
23
+
resolvedUri = `at://${did}/at.margin.collection/${rkey}`;
24
24
+
const [ogData, col] = await Promise.all([
25
25
+
fetchCollectionOG(resolvedUri),
26
26
+
getCollection(cookie, resolvedUri),
27
27
+
]);
28
28
+
if (ogData) {
29
29
+
title = ogData.title;
30
30
+
description = ogData.description;
31
31
+
image = ogData.image;
32
32
+
}
33
33
+
if (col) {
34
34
+
initialCollection = col;
35
35
+
initialItems = await getCollectionItems(cookie, col.uri);
24
36
}
25
37
}
26
38
} catch (e) {
27
27
-
console.error('OG fetch error (collection):', e);
39
39
+
console.error('OG/data fetch error (collection):', e);
28
40
}
29
41
}
30
42
---
31
43
32
44
<AppLayout title={title} description={description} image={image} user={user}>
33
33
-
<CollectionDetail client:load handle={handle} rkey={rkey} />
45
45
+
<CollectionDetail client:idle handle={handle} rkey={rkey}
46
46
+
initialCollection={initialCollection} initialItems={initialItems} resolvedUri={resolvedUri} />
34
47
</AppLayout>
+20
-7
web/src/pages/[handle]/highlight/[rkey].astro
···
3
3
import AppLayout from '../../../layouts/AppLayout.astro';
4
4
import AnnotationDetail from '../../../views/content/AnnotationDetail';
5
5
import { resolveHandle, fetchOGForRoute } from '../../../lib/og';
6
6
+
import { getAnnotation, getReplies } from '../../../lib/api';
6
7
7
8
const { handle, rkey } = Astro.params;
8
9
const user = Astro.locals.user;
10
10
+
const cookie = Astro.request.headers.get('cookie') || '';
9
11
10
12
let title = 'Highlight - Margin';
11
13
let description = 'Annotate the web';
12
14
let image = 'https://margin.at/og.png';
15
15
+
let initialAnnotation = null;
16
16
+
let initialReplies: any[] = [];
17
17
+
let resolvedUri = '';
13
18
14
19
if (handle && rkey) {
15
20
try {
16
21
const did = await resolveHandle(handle);
17
22
if (did) {
18
18
-
const data = await fetchOGForRoute(did, rkey, 'at.margin.highlight');
19
19
-
if (data) {
20
20
-
title = data.title;
21
21
-
description = data.description;
22
22
-
image = data.image;
23
23
+
resolvedUri = `at://${did}/at.margin.highlight/${rkey}`;
24
24
+
const [ogData, annData, repData] = await Promise.all([
25
25
+
fetchOGForRoute(did, rkey, 'at.margin.highlight'),
26
26
+
getAnnotation(cookie, resolvedUri),
27
27
+
getReplies(cookie, resolvedUri),
28
28
+
]);
29
29
+
if (ogData) {
30
30
+
title = ogData.title;
31
31
+
description = ogData.description;
32
32
+
image = ogData.image;
23
33
}
34
34
+
initialAnnotation = annData;
35
35
+
initialReplies = repData;
24
36
}
25
37
} catch (e) {
26
26
-
console.error('OG fetch error (highlight):', e);
38
38
+
console.error('OG/data fetch error (highlight):', e);
27
39
}
28
40
}
29
41
---
30
42
31
43
<AppLayout title={title} description={description} image={image} user={user}>
32
32
-
<AnnotationDetail client:load handle={handle} rkey={rkey} type="highlight" />
44
44
+
<AnnotationDetail client:idle handle={handle} rkey={rkey} type="highlight"
45
45
+
initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} />
33
46
</AppLayout>
+23
-6
web/src/pages/at/[did]/[rkey].astro
···
3
3
import AppLayout from '../../../layouts/AppLayout.astro';
4
4
import AnnotationDetail from '../../../views/content/AnnotationDetail';
5
5
import { fetchOGForRoute } from '../../../lib/og';
6
6
+
import { getAnnotation, getReplies } from '../../../lib/api';
6
7
7
8
const { did, rkey } = Astro.params;
8
9
const user = Astro.locals.user;
10
10
+
const cookie = Astro.request.headers.get('cookie') || '';
9
11
10
12
let title = 'Margin';
11
13
let description = 'Annotate the web';
12
14
let image = 'https://margin.at/og.png';
15
15
+
let initialAnnotation = null;
16
16
+
let initialReplies: any[] = [];
17
17
+
let resolvedUri = '';
13
18
14
19
if (did && rkey) {
15
15
-
const data = await fetchOGForRoute(did, rkey);
16
16
-
if (data) {
17
17
-
title = data.title;
18
18
-
description = data.description;
19
19
-
image = data.image;
20
20
+
try {
21
21
+
resolvedUri = `at://${did}/at.margin.annotation/${rkey}`;
22
22
+
const [ogData, annData, repData] = await Promise.all([
23
23
+
fetchOGForRoute(did, rkey),
24
24
+
getAnnotation(cookie, resolvedUri),
25
25
+
getReplies(cookie, resolvedUri),
26
26
+
]);
27
27
+
if (ogData) {
28
28
+
title = ogData.title;
29
29
+
description = ogData.description;
30
30
+
image = ogData.image;
31
31
+
}
32
32
+
initialAnnotation = annData;
33
33
+
initialReplies = repData;
34
34
+
} catch (e) {
35
35
+
console.error('OG/data fetch error:', e);
20
36
}
21
37
}
22
38
---
23
39
24
40
<AppLayout title={title} description={description} image={image} user={user}>
25
25
-
<AnnotationDetail client:load did={did} rkey={rkey} />
41
41
+
<AnnotationDetail client:idle did={did} rkey={rkey}
42
42
+
initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} />
26
43
</AppLayout>
+21
-2
web/src/pages/collections/[rkey].astro
···
2
2
3
3
import AppLayout from '../../layouts/AppLayout.astro';
4
4
import CollectionDetail from '../../views/collections/CollectionDetail';
5
5
+
import { getCollection, getCollectionItems } from '../../lib/api';
5
6
6
7
const { rkey } = Astro.params;
7
8
const user = Astro.locals.user;
9
9
+
const cookie = Astro.request.headers.get('cookie') || '';
10
10
+
11
11
+
let initialCollection = null;
12
12
+
let initialItems: any[] = [];
13
13
+
let resolvedUri = '';
14
14
+
let pageTitle = 'Collection - Margin';
15
15
+
16
16
+
if (user && rkey) {
17
17
+
try {
18
18
+
resolvedUri = `at://${user.did}/at.margin.collection/${rkey}`;
19
19
+
const col = await getCollection(cookie, resolvedUri);
20
20
+
if (col) {
21
21
+
initialCollection = col;
22
22
+
pageTitle = `${col.name || 'Collection'} - Margin`;
23
23
+
initialItems = await getCollectionItems(cookie, col.uri);
24
24
+
}
25
25
+
} catch { /* component will fetch client-side */ }
26
26
+
}
8
27
---
9
28
10
10
-
<AppLayout title="Collection - Margin" user={user}>
11
11
-
<CollectionDetail client:load rkey={rkey} />
29
29
+
<AppLayout title={pageTitle} user={user}>
30
30
+
<CollectionDetail client:idle rkey={rkey} initialCollection={initialCollection} initialItems={initialItems} resolvedUri={resolvedUri} />
12
31
</AppLayout>
+16
-2
web/src/pages/profile/[did].astro
···
2
2
3
3
import AppLayout from '../../layouts/AppLayout.astro';
4
4
import Profile from '../../views/profile/Profile';
5
5
+
import { getProfile } from '../../lib/api';
5
6
6
7
const { did } = Astro.params;
7
8
const user = Astro.locals.user;
···
9
10
if (!did) {
10
11
return Astro.redirect('/home');
11
12
}
13
13
+
14
14
+
const cookie = Astro.request.headers.get('cookie') || '';
15
15
+
let initialProfile = null;
16
16
+
let pageTitle = 'Profile - Margin';
17
17
+
18
18
+
try {
19
19
+
initialProfile = await getProfile(cookie, did);
20
20
+
if (initialProfile?.displayName) {
21
21
+
pageTitle = `${initialProfile.displayName} - Margin`;
22
22
+
} else if (initialProfile?.handle) {
23
23
+
pageTitle = `@${initialProfile.handle} - Margin`;
24
24
+
}
25
25
+
} catch { /* component will fetch client-side */ }
12
26
---
13
27
14
14
-
<AppLayout title="Profile - Margin" user={user}>
15
15
-
<Profile client:load did={did} />
28
28
+
<AppLayout title={pageTitle} user={user}>
29
29
+
<Profile client:load did={did} initialProfile={initialProfile} />
16
30
</AppLayout>
+2
-2
web/src/pages/search.astro
···
1
1
---
2
2
3
3
import AppLayout from '../layouts/AppLayout.astro';
4
4
-
import Search from '../views/core/Search';
4
4
+
import SearchView from '../views/core/Search';
5
5
6
6
const user = Astro.locals.user;
7
7
const q = Astro.url.searchParams.get('q') || undefined;
8
8
---
9
9
10
10
<AppLayout title={q ? `Search: ${q} - Margin` : 'Search - Margin'} user={user}>
11
11
-
<Search client:load initialQuery={q} />
11
11
+
<SearchView client:load initialQuery={q} />
12
12
</AppLayout>
-9
web/src/store/auth.ts
···
1
1
import { atom } from "nanostores";
2
2
-
import { checkSession } from "../api/client";
3
2
import { loadPreferences } from "./preferences";
4
3
import type { UserProfile } from "../types";
5
4
6
5
export const $user = atom<UserProfile | null>(null);
7
7
-
export const $isLoading = atom<boolean>(true);
8
6
9
7
$user.subscribe((user) => {
10
8
if (user) {
11
9
loadPreferences();
12
10
}
13
11
});
14
14
-
15
15
-
export async function initAuth() {
16
16
-
$isLoading.set(true);
17
17
-
const session = await checkSession();
18
18
-
$user.set(session);
19
19
-
$isLoading.set(false);
20
20
-
}
21
12
22
13
export function logout() {
23
14
fetch("/auth/logout", { method: "POST" }).then(() => {
+15
-5
web/src/views/collections/CollectionDetail.tsx
···
20
20
handle?: string;
21
21
rkey?: string;
22
22
uri?: string;
23
23
+
initialCollection?: Collection | null;
24
24
+
initialItems?: AnnotationItem[];
25
25
+
resolvedUri?: string;
23
26
}
24
27
25
28
export default function CollectionDetail({
26
29
handle,
27
30
rkey,
28
31
uri,
32
32
+
initialCollection,
33
33
+
initialItems,
34
34
+
resolvedUri,
29
35
}: CollectionDetailProps) {
30
36
const user = useStore($user);
31
31
-
const [collection, setCollection] = useState<Collection | null>(null);
32
32
-
const [items, setItems] = useState<AnnotationItem[]>([]);
33
33
-
const [loading, setLoading] = useState(true);
37
37
+
const [collection, setCollection] = useState<Collection | null>(
38
38
+
initialCollection || null,
39
39
+
);
40
40
+
const [items, setItems] = useState<AnnotationItem[]>(initialItems || []);
41
41
+
const [loading, setLoading] = useState(!initialCollection);
34
42
const [error, setError] = useState<string | null>(null);
35
43
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
36
44
37
45
useEffect(() => {
46
46
+
if (initialCollection) return;
47
47
+
38
48
const loadData = async () => {
39
49
setLoading(true);
40
50
try {
41
41
-
let targetUri = uri;
51
51
+
let targetUri = resolvedUri || uri;
42
52
if (!targetUri && handle && rkey) {
43
53
if (handle.startsWith("did:")) {
44
54
targetUri = `at://${handle}/at.margin.collection/${rkey}`;
···
72
82
};
73
83
74
84
loadData();
75
75
-
}, [handle, rkey, uri]);
85
85
+
}, [handle, rkey, uri, initialCollection, resolvedUri]);
76
86
77
87
const handleDelete = async () => {
78
88
if (!collection) return;
+44
-25
web/src/views/collections/Collections.tsx
···
4
4
createCollection,
5
5
deleteCollection,
6
6
} from "../../api/client";
7
7
-
import { Plus, Folder, Trash2, X } from "lucide-react";
7
7
+
import { Plus, Folder, Trash2, X, Loader2 } from "lucide-react";
8
8
import CollectionIcon from "../../components/common/CollectionIcon";
9
9
import { ICON_MAP } from "../../components/common/iconMap";
10
10
import { useStore } from "@nanostores/react";
11
11
import { $user } from "../../store/auth";
12
12
-
import EmojiPicker, { Theme } from "emoji-picker-react";
12
12
+
import { Theme } from "emoji-picker-react";
13
13
+
const EmojiPicker = React.lazy(() => import("emoji-picker-react"));
13
14
import { $theme } from "../../store/theme";
14
15
import type { Collection } from "../../types";
15
16
import { formatDistanceToNow } from "date-fns";
···
21
22
timestamp: 0,
22
23
};
23
24
24
24
-
export default function Collections() {
25
25
+
interface CollectionsProps {
26
26
+
initialCollections?: Collection[];
27
27
+
}
28
28
+
29
29
+
export default function Collections({ initialCollections }: CollectionsProps) {
25
30
const user = useStore($user);
26
31
const theme = useStore($theme);
27
27
-
const [collections, setCollections] = useState<Collection[]>([]);
28
28
-
const [loading, setLoading] = useState(true);
32
32
+
const [collections, setCollections] = useState<Collection[]>(
33
33
+
Array.isArray(initialCollections) ? initialCollections : [],
34
34
+
);
35
35
+
const [loading, setLoading] = useState(!Array.isArray(initialCollections));
29
36
const [showCreateModal, setShowCreateModal] = useState(false);
30
37
const [newItemName, setNewItemName] = useState("");
31
38
const [newItemDesc, setNewItemDesc] = useState("");
···
65
72
};
66
73
67
74
useEffect(() => {
75
75
+
if (initialCollections) return;
68
76
fetchCollections();
69
69
-
}, []);
77
77
+
}, [initialCollections]);
70
78
71
79
const handleCreate = async (e: React.FormEvent) => {
72
80
e.preventDefault();
···
277
285
</div>
278
286
) : (
279
287
<div className="w-full bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
280
280
-
<EmojiPicker
281
281
-
className="custom-emoji-picker"
282
282
-
onEmojiClick={(emojiData) =>
283
283
-
setNewItemIcon(emojiData.emoji)
288
288
+
<React.Suspense
289
289
+
fallback={
290
290
+
<div className="flex items-center justify-center h-[300px]">
291
291
+
<Loader2
292
292
+
className="animate-spin text-surface-400"
293
293
+
size={24}
294
294
+
/>
295
295
+
</div>
284
296
}
285
285
-
autoFocusSearch={false}
286
286
-
width="100%"
287
287
-
height={300}
288
288
-
previewConfig={{ showPreview: false }}
289
289
-
skinTonesDisabled
290
290
-
lazyLoadEmojis
291
291
-
theme={
292
292
-
theme === "dark" ||
293
293
-
(theme === "system" &&
294
294
-
window.matchMedia("(prefers-color-scheme: dark)")
295
295
-
.matches)
296
296
-
? (Theme.DARK as Theme)
297
297
-
: (Theme.LIGHT as Theme)
298
298
-
}
299
299
-
/>
297
297
+
>
298
298
+
<EmojiPicker
299
299
+
className="custom-emoji-picker"
300
300
+
onEmojiClick={(emojiData) =>
301
301
+
setNewItemIcon(emojiData.emoji)
302
302
+
}
303
303
+
autoFocusSearch={false}
304
304
+
width="100%"
305
305
+
height={300}
306
306
+
previewConfig={{ showPreview: false }}
307
307
+
skinTonesDisabled
308
308
+
lazyLoadEmojis
309
309
+
theme={
310
310
+
theme === "dark" ||
311
311
+
(theme === "system" &&
312
312
+
window.matchMedia("(prefers-color-scheme: dark)")
313
313
+
.matches)
314
314
+
? (Theme.DARK as Theme)
315
315
+
: (Theme.LIGHT as Theme)
316
316
+
}
317
317
+
/>
318
318
+
</React.Suspense>
300
319
</div>
301
320
)}
302
321
</div>
+26
-6
web/src/views/content/AnnotationDetail.tsx
···
1
1
-
import React, { useEffect, useState } from "react";
1
1
+
import React, { useEffect, useRef, useState } from "react";
2
2
import { useStore } from "@nanostores/react";
3
3
import { $user } from "../../store/auth";
4
4
import {
···
26
26
type?: string;
27
27
uri?: string;
28
28
did?: string;
29
29
+
initialAnnotation?: AnnotationItem | null;
30
30
+
initialReplies?: AnnotationItem[];
31
31
+
resolvedUri?: string;
29
32
}
30
33
31
34
export default function AnnotationDetail({
···
34
37
type,
35
38
uri,
36
39
did,
40
40
+
initialAnnotation,
41
41
+
initialReplies,
42
42
+
resolvedUri,
37
43
}: AnnotationDetailProps) {
38
44
const user = useStore($user);
39
45
40
40
-
const [annotation, setAnnotation] = useState<AnnotationItem | null>(null);
41
41
-
const [replies, setReplies] = useState<AnnotationItem[]>([]);
42
42
-
const [loading, setLoading] = useState(true);
46
46
+
const [annotation, setAnnotation] = useState<AnnotationItem | null>(
47
47
+
initialAnnotation || null,
48
48
+
);
49
49
+
const [replies, setReplies] = useState<AnnotationItem[]>(
50
50
+
initialReplies || [],
51
51
+
);
52
52
+
const [loading, setLoading] = useState(!initialAnnotation);
43
53
const [error, setError] = useState<string | null>(null);
44
54
45
55
const [replyText, setReplyText] = useState("");
46
56
const [posting, setPosting] = useState(false);
47
57
const [replyingTo, setReplyingTo] = useState<AnnotationItem | null>(null);
48
58
49
49
-
const [targetUri, setTargetUri] = useState<string | null>(uri || null);
59
59
+
const [targetUri, setTargetUri] = useState<string | null>(
60
60
+
resolvedUri || uri || null,
61
61
+
);
62
62
+
const skipInitialFetch = useRef(!!initialAnnotation);
50
63
51
64
useEffect(() => {
65
65
+
if (resolvedUri) return;
66
66
+
52
67
async function resolve() {
53
68
if (uri) {
54
69
setTargetUri(decodeURIComponent(uri));
···
79
94
}
80
95
}
81
96
resolve();
82
82
-
}, [uri, did, rkey, handle, type]);
97
97
+
}, [uri, did, rkey, handle, type, resolvedUri]);
83
98
84
99
const refreshReplies = async () => {
85
100
if (!targetUri) return;
···
88
103
};
89
104
90
105
useEffect(() => {
106
106
+
if (skipInitialFetch.current) {
107
107
+
skipInitialFetch.current = false;
108
108
+
return;
109
109
+
}
110
110
+
91
111
async function fetchData() {
92
112
if (!targetUri) return;
93
113
+18
-5
web/src/views/core/Discover.tsx
···
10
10
import { $feedLayout } from "../../store/feedLayout";
11
11
import { formatDistanceToNow } from "date-fns";
12
12
13
13
-
export default function Discover() {
13
13
+
interface DiscoverProps {
14
14
+
initialDocuments?: DocumentItem[];
15
15
+
initialHasMore?: boolean;
16
16
+
}
17
17
+
18
18
+
export default function Discover({
19
19
+
initialDocuments,
20
20
+
initialHasMore,
21
21
+
}: DiscoverProps) {
14
22
const user = useStore($user);
15
23
const layout = useStore($feedLayout);
16
24
const [activeTab, setActiveTab] = useState("new");
17
17
-
const [items, setItems] = useState<DocumentItem[]>([]);
18
18
-
const [loading, setLoading] = useState(true);
19
19
-
const [hasMore, setHasMore] = useState(false);
20
20
-
const [offset, setOffset] = useState(0);
25
25
+
const [items, setItems] = useState<DocumentItem[]>(initialDocuments || []);
26
26
+
const [loading, setLoading] = useState(!initialDocuments);
27
27
+
const [hasMore, setHasMore] = useState(initialHasMore ?? false);
28
28
+
const [offset, setOffset] = useState(initialDocuments?.length ?? 0);
21
29
const [recommendationsUnavailable, setRecommendationsUnavailable] =
22
30
useState(false);
23
31
const fetchIdRef = useRef(0);
···
61
69
[limit],
62
70
);
63
71
72
72
+
const skipInitialFetch = useRef(!!initialDocuments);
64
73
useEffect(() => {
74
74
+
if (skipInitialFetch.current) {
75
75
+
skipInitialFetch.current = false;
76
76
+
return;
77
77
+
}
65
78
queueMicrotask(() => fetchItems(activeTab, 0));
66
79
}, [activeTab, fetchItems]);
67
80
+81
-33
web/src/views/core/Feed.tsx
···
1
1
import { useStore } from "@nanostores/react";
2
2
import { clsx } from "clsx";
3
3
-
import { Bookmark, Highlighter, MessageSquareText } from "lucide-react";
3
3
+
import {
4
4
+
Bookmark,
5
5
+
Highlighter,
6
6
+
MessageSquareText,
7
7
+
User,
8
8
+
Users,
9
9
+
} from "lucide-react";
4
10
import { useState } from "react";
5
11
import FeedItems from "../../components/feed/FeedItems";
6
12
import { Button, Tabs } from "../../components/ui";
7
13
import LayoutToggle from "../../components/ui/LayoutToggle";
8
14
import { $user } from "../../store/auth";
9
15
import { $feedLayout } from "../../store/feedLayout";
10
10
-
import type { UserProfile } from "../../types";
16
16
+
import type { AnnotationItem, UserProfile } from "../../types";
11
17
12
18
interface FeedProps {
13
19
initialType?: string;
···
16
22
motivation?: string;
17
23
showTabs?: boolean;
18
24
emptyMessage?: string;
25
25
+
initialItems?: AnnotationItem[];
26
26
+
initialHasMore?: boolean;
19
27
}
20
28
21
29
export default function Feed({
···
25
33
motivation,
26
34
showTabs = true,
27
35
emptyMessage = "No items found.",
36
36
+
initialItems,
37
37
+
initialHasMore,
28
38
}: FeedProps) {
29
39
const [tag, setTag] = useState<string | undefined>(
30
40
initialTag ||
···
39
49
const [activeFilter, setActiveFilter] = useState<string | undefined>(
40
50
motivation,
41
51
);
52
52
+
const [mineOnly, setMineOnly] = useState(false);
42
53
43
54
const clearTag = () => {
44
55
setTag(undefined);
···
102
113
</div>
103
114
)}
104
115
105
105
-
{showTabs && (
106
106
-
<div className="sticky top-0 z-10 bg-white/90 dark:bg-surface-800/90 backdrop-blur-md pb-3 mb-2 -mx-1 px-1 pt-2 space-y-2">
107
107
-
{!tag && (
108
108
-
<Tabs
109
109
-
tabs={tabs}
110
110
-
activeTab={activeTab}
111
111
-
onChange={handleTabChange}
112
112
-
/>
113
113
-
)}
114
114
-
{tag && (
115
115
-
<div className="flex items-center justify-between mb-2">
116
116
-
<h2 className="text-xl font-bold flex items-center gap-2">
117
117
-
<span className="text-surface-500 font-normal">
118
118
-
Items with tag:
119
119
-
</span>
120
120
-
<span className="bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 px-2 py-0.5 rounded-lg">
121
121
-
#{tag}
122
122
-
</span>
123
123
-
</h2>
124
124
-
<button
125
125
-
onClick={clearTag}
126
126
-
className="text-sm text-surface-500 hover:text-surface-900 dark:hover:text-white"
127
127
-
>
128
128
-
Clear filter
129
129
-
</button>
130
130
-
</div>
131
131
-
)}
116
116
+
<div className="sticky top-0 z-10 bg-white/90 dark:bg-surface-800/90 backdrop-blur-md pb-3 mb-2 -mx-1 px-1 pt-2 space-y-2">
117
117
+
{showTabs && !tag && (
118
118
+
<Tabs tabs={tabs} activeTab={activeTab} onChange={handleTabChange} />
119
119
+
)}
120
120
+
{tag && (
121
121
+
<div className="flex items-center justify-between mb-2">
122
122
+
<h2 className="text-xl font-bold flex items-center gap-2">
123
123
+
<span className="text-surface-500 font-normal">
124
124
+
Items with tag:
125
125
+
</span>
126
126
+
<span className="bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 px-2 py-0.5 rounded-lg">
127
127
+
#{tag}
128
128
+
</span>
129
129
+
</h2>
130
130
+
<button
131
131
+
onClick={clearTag}
132
132
+
className="text-sm text-surface-500 hover:text-surface-900 dark:hover:text-white"
133
133
+
>
134
134
+
Clear filter
135
135
+
</button>
136
136
+
</div>
137
137
+
)}
138
138
+
{showTabs && (
132
139
<div className="flex items-center gap-1.5 flex-wrap">
133
140
{filters.map((f) => {
134
141
const isActive =
···
153
160
<LayoutToggle className="hidden sm:inline-flex" />
154
161
</div>
155
162
</div>
156
156
-
</div>
157
157
-
)}
163
163
+
)}
164
164
+
{!showTabs && user && (
165
165
+
<div className="flex items-center gap-1.5">
166
166
+
{[
167
167
+
{ id: "everyone", label: "Everyone", icon: Users },
168
168
+
{ id: "mine", label: "Mine", icon: User },
169
169
+
].map((f) => {
170
170
+
const isActive = f.id === "mine" ? mineOnly : !mineOnly;
171
171
+
return (
172
172
+
<button
173
173
+
key={f.id}
174
174
+
onClick={() => setMineOnly(f.id === "mine")}
175
175
+
className={clsx(
176
176
+
"inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all",
177
177
+
isActive
178
178
+
? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm"
179
179
+
: "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400",
180
180
+
)}
181
181
+
>
182
182
+
<f.icon size={12} />
183
183
+
{f.label}
184
184
+
</button>
185
185
+
);
186
186
+
})}
187
187
+
<div className="ml-auto">
188
188
+
<LayoutToggle className="hidden sm:inline-flex" />
189
189
+
</div>
190
190
+
</div>
191
191
+
)}
192
192
+
</div>
158
193
159
194
<FeedItems
160
160
-
key={`${activeTab}-${activeFilter || "all"}-${tag || ""}`}
195
195
+
key={`${activeTab}-${activeFilter || "all"}-${tag || ""}-${mineOnly ? "mine" : "all"}`}
161
196
type={activeTab === "atmosphereconf" ? "all" : activeTab}
162
197
motivation={activeFilter}
198
198
+
creator={mineOnly && user ? user.did : undefined}
163
199
emptyMessage={emptyMessage}
164
200
layout={layout}
165
165
-
tag={activeTab === "atmosphereconf" ? "atmosphereconf" : tag?.toLowerCase()}
201
201
+
tag={
202
202
+
activeTab === "atmosphereconf" ? "atmosphereconf" : tag?.toLowerCase()
203
203
+
}
204
204
+
initialItems={
205
205
+
activeTab === initialType && activeFilter === motivation && !mineOnly
206
206
+
? initialItems
207
207
+
: undefined
208
208
+
}
209
209
+
initialHasMore={
210
210
+
activeTab === initialType && activeFilter === motivation && !mineOnly
211
211
+
? initialHasMore
212
212
+
: undefined
213
213
+
}
166
214
/>
167
215
</div>
168
216
);
+17
-4
web/src/views/core/Notifications.tsx
···
222
222
);
223
223
}
224
224
225
225
-
export default function Notifications() {
226
226
-
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
227
227
-
const [loading, setLoading] = useState(true);
225
225
+
interface NotificationsProps {
226
226
+
initialNotifications?: NotificationItem[];
227
227
+
}
228
228
+
229
229
+
export default function Notifications({
230
230
+
initialNotifications,
231
231
+
}: NotificationsProps) {
232
232
+
const [notifications, setNotifications] = useState<NotificationItem[]>(
233
233
+
initialNotifications || [],
234
234
+
);
235
235
+
const [loading, setLoading] = useState(!initialNotifications);
228
236
229
237
useEffect(() => {
238
238
+
if (initialNotifications) {
239
239
+
markNotificationsRead();
240
240
+
return;
241
241
+
}
242
242
+
230
243
const load = async () => {
231
244
if (
232
245
notificationsCache.data &&
···
256
269
markNotificationsRead();
257
270
};
258
271
load();
259
259
-
}, []);
272
272
+
}, [initialNotifications]);
260
273
261
274
if (loading) {
262
275
return (
+17
-4
web/src/views/core/Search.tsx
···
29
29
30
30
interface SearchProps {
31
31
initialQuery?: string;
32
32
+
initialResults?: AnnotationItem[];
33
33
+
initialHasMore?: boolean;
32
34
}
33
35
34
34
-
export default function Search({ initialQuery = "" }: SearchProps) {
36
36
+
export default function Search({
37
37
+
initialQuery = "",
38
38
+
initialResults,
39
39
+
initialHasMore,
40
40
+
}: SearchProps) {
35
41
const user = useStore($user);
36
42
const layout = useStore($feedLayout);
37
43
38
44
const [query, setQuery] = useState(initialQuery);
39
39
-
const [results, setResults] = useState<AnnotationItem[]>([]);
45
45
+
const [results, setResults] = useState<AnnotationItem[]>(
46
46
+
initialResults || [],
47
47
+
);
40
48
const [loading, setLoading] = useState(false);
41
41
-
const [hasMore, setHasMore] = useState(false);
42
42
-
const [offset, setOffset] = useState(0);
49
49
+
const [hasMore, setHasMore] = useState(initialHasMore ?? false);
50
50
+
const [offset, setOffset] = useState(initialResults?.length ?? 0);
43
51
const [myItemsOnly, setMyItemsOnly] = useState(false);
44
52
const [activeFilter, setActiveFilter] = useState<string | undefined>(
45
53
undefined,
···
139
147
[user],
140
148
);
141
149
150
150
+
const skipInitialSearch = useRef(!!initialResults);
142
151
useEffect(() => {
152
152
+
if (skipInitialSearch.current) {
153
153
+
skipInitialSearch.current = false;
154
154
+
return;
155
155
+
}
143
156
if (initialQuery) {
144
157
// eslint-disable-next-line react-hooks/set-state-in-effect
145
158
doSearch(initialQuery);
+18
-9
web/src/views/profile/Profile.tsx
···
70
70
71
71
interface ProfileProps {
72
72
did: string;
73
73
+
initialProfile?: UserProfile | null;
73
74
}
74
75
75
76
type Tab = "all" | "annotations" | "highlights" | "bookmarks" | "collections";
···
82
83
collections: undefined,
83
84
};
84
85
85
85
-
export default function Profile({ did }: ProfileProps) {
86
86
-
const [profile, setProfile] = useState<UserProfile | null>(null);
87
87
-
const [loading, setLoading] = useState(true);
86
86
+
export default function Profile({ did, initialProfile }: ProfileProps) {
87
87
+
const [profile, setProfile] = useState<UserProfile | null>(
88
88
+
initialProfile || null,
89
89
+
);
90
90
+
const [loading, setLoading] = useState(!initialProfile);
88
91
const [activeTab, setActiveTab] = useState<Tab>("all");
89
92
90
93
const [collections, setCollections] = useState<Collection[]>([]);
···
131
134
}
132
135
};
133
136
137
137
+
const skipInitialProfileFetch = useRef(!!initialProfile);
134
138
useEffect(() => {
135
135
-
setProfile(null);
136
136
-
setCollections([]);
137
137
-
setActiveTab("all");
138
138
-
setLoading(true);
139
139
+
if (skipInitialProfileFetch.current) {
140
140
+
skipInitialProfileFetch.current = false;
141
141
+
} else {
142
142
+
setProfile(null);
143
143
+
setCollections([]);
144
144
+
setActiveTab("all");
145
145
+
setLoading(true);
146
146
+
}
139
147
140
148
const loadProfile = async () => {
141
149
const cached = profileCache.get(did);
···
144
152
setAccountLabels(cached.labels);
145
153
setModRelation(cached.relation);
146
154
setLoading(false);
147
147
-
} else {
155
155
+
} else if (!initialProfile) {
148
156
setLoading(true);
149
157
}
150
158
···
215
223
}
216
224
};
217
225
if (did) loadProfile();
218
218
-
}, [did, user]);
226
226
+
// eslint-disable-next-line react-hooks/exhaustive-deps
227
227
+
}, [did, user, initialProfile]);
219
228
220
229
useEffect(() => {
221
230
loadPreferences();