(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

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