Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

fixes and optimizations that may or may not break margin

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