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

Add iOS shortcut and fix being able to make duplicate bookmarks

scanash00 d76abc06 92fc8b21

+186 -30
+1 -2
backend/internal/api/annotations.go
··· 638 638 var result *xrpc.CreateRecordOutput 639 639 640 640 if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { 641 - w.Header().Set("Content-Type", "application/json") 642 - json.NewEncoder(w).Encode(map[string]string{"uri": existing.URI, "cid": *existing.CID}) 641 + http.Error(w, "Bookmark already exists", http.StatusConflict) 643 642 return 644 643 } 645 644
+4 -3
backend/internal/api/annotations_helpers.go
··· 46 46 } 47 47 48 48 func (s *AnnotationService) checkDuplicateBookmark(did, url string) (*db.Bookmark, error) { 49 - recentBooks, err := s.db.GetBookmarksByAuthor(did, 5, 0) 49 + urlHash := db.HashURL(url) 50 + bookmarks, err := s.db.GetBookmarksByTargetHash(urlHash, 50, 0) 50 51 if err != nil { 51 52 return nil, err 52 53 } 53 - for _, b := range recentBooks { 54 - if b.Source == url && time.Since(b.CreatedAt) < 10*time.Second { 54 + for _, b := range bookmarks { 55 + if b.AuthorDID == did && b.Source == url { 55 56 return &b, nil 56 57 } 57 58 }
+3
backend/internal/api/handler.go
··· 614 614 615 615 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 616 616 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 617 + bookmarks, _ := h.db.GetBookmarksByTargetHash(urlHash, limit, offset) 617 618 618 619 enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 619 620 enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 621 + enrichedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 620 622 621 623 w.Header().Set("Content-Type", "application/json") 622 624 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 625 627 "sourceHash": urlHash, 626 628 "annotations": enrichedAnnotations, 627 629 "highlights": enrichedHighlights, 630 + "bookmarks": enrichedBookmarks, 628 631 }) 629 632 } 630 633
+24
backend/internal/db/queries_bookmarks.go
··· 194 194 } 195 195 return uris, nil 196 196 } 197 + 198 + func (db *DB) GetBookmarksByTargetHash(targetHash string, limit, offset int) ([]Bookmark, error) { 199 + rows, err := db.Query(db.Rebind(` 200 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 201 + FROM bookmarks 202 + WHERE source_hash = ? 203 + ORDER BY created_at DESC 204 + LIMIT ? OFFSET ? 205 + `), targetHash, limit, offset) 206 + if err != nil { 207 + return nil, err 208 + } 209 + defer rows.Close() 210 + 211 + var bookmarks []Bookmark 212 + for rows.Next() { 213 + var b Bookmark 214 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 215 + return nil, err 216 + } 217 + bookmarks = append(bookmarks, b) 218 + } 219 + return bookmarks, nil 220 + }
+1
extension/background/service-worker.js
··· 384 384 const items = [ 385 385 ...(data.annotations || []), 386 386 ...(data.highlights || []), 387 + ...(data.bookmarks || []), 387 388 ]; 388 389 items.forEach((item) => { 389 390 const id = item.uri || item.id;
+18 -1
extension/popup/popup.js
··· 425 425 }); 426 426 427 427 if (res.success) { 428 - renderAnnotations(res.data); 428 + if (currentUserDid) { 429 + const isBookmarked = res.data.some( 430 + (item) => 431 + item.type === "Bookmark" && item.creator.did === currentUserDid, 432 + ); 433 + if (els.bookmarkBtn) { 434 + if (isBookmarked) { 435 + els.bookmarkBtn.textContent = "✓ Bookmarked"; 436 + els.bookmarkBtn.disabled = true; 437 + } else { 438 + els.bookmarkBtn.textContent = "Bookmark Page"; 439 + els.bookmarkBtn.disabled = false; 440 + } 441 + } 442 + } 443 + 444 + const listItems = res.data.filter((item) => item.type !== "Bookmark"); 445 + renderAnnotations(listItems); 429 446 } 430 447 } catch (err) { 431 448 console.error("Load annotations error:", err);
+18 -1
extension/sidepanel/sidepanel.js
··· 357 357 }); 358 358 359 359 if (res.success) { 360 - renderAnnotations(res.data); 360 + if (currentUserDid) { 361 + const isBookmarked = res.data.some( 362 + (item) => 363 + item.type === "Bookmark" && item.creator.did === currentUserDid, 364 + ); 365 + if (els.bookmarkBtn) { 366 + if (isBookmarked) { 367 + els.bookmarkBtn.textContent = "✓ Bookmarked"; 368 + els.bookmarkBtn.disabled = true; 369 + } else { 370 + els.bookmarkBtn.textContent = "Bookmark Page"; 371 + els.bookmarkBtn.disabled = false; 372 + } 373 + } 374 + } 375 + 376 + const listItems = res.data.filter((item) => item.type !== "Bookmark"); 377 + renderAnnotations(listItems); 361 378 } 362 379 } catch (err) { 363 380 console.error("Load annotations error:", err);
+2
web/src/css/base.css
··· 30 30 "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 31 31 --font-mono: 32 32 "JetBrains Mono", source-code-pro, Menlo, Monaco, Consolas, monospace; 33 + --nav-bg: rgba(9, 9, 11, 0.9); 33 34 } 34 35 35 36 [data-theme="light"] { ··· 54 55 --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 55 56 --shadow-md: 56 57 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 58 + --nav-bg: rgba(255, 255, 255, 0.9); 57 59 } 58 60 59 61 * {
+1 -1
web/src/css/layout.css
··· 407 407 bottom: 0; 408 408 left: 0; 409 409 right: 0; 410 - background: rgba(9, 9, 11, 0.9); 410 + background: var(--nav-bg); 411 411 backdrop-filter: blur(12px); 412 412 -webkit-backdrop-filter: blur(12px); 413 413 border-top: 1px solid var(--border);
+84 -13
web/src/pages/Feed.jsx
··· 39 39 uri: null, 40 40 }); 41 41 42 + const [showIosBanner, setShowIosBanner] = useState(false); 43 + 44 + useEffect(() => { 45 + const isIOS = 46 + /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 47 + const hasDismissed = localStorage.getItem("iosBannerDismissed"); 48 + 49 + if (isIOS && !hasDismissed) { 50 + setShowIosBanner(true); 51 + } 52 + }, []); 53 + 54 + const dismissIosBanner = () => { 55 + setShowIosBanner(false); 56 + localStorage.setItem("iosBannerDismissed", "true"); 57 + }; 58 + 42 59 const { user } = useAuth(); 43 60 44 61 useEffect(() => { ··· 77 94 78 95 const filteredAnnotations = 79 96 feedType === "all" || 80 - feedType === "popular" || 81 - feedType === "semble" || 82 - feedType === "margin" || 83 - feedType === "my-feed" 97 + feedType === "popular" || 98 + feedType === "semble" || 99 + feedType === "margin" || 100 + feedType === "my-feed" 84 101 ? filter === "all" 85 102 ? annotations 86 103 : annotations.filter((a) => { 87 - if (filter === "commenting") 88 - return a.motivation === "commenting" || a.type === "Annotation"; 89 - if (filter === "highlighting") 90 - return a.motivation === "highlighting" || a.type === "Highlight"; 91 - if (filter === "bookmarking") 92 - return a.motivation === "bookmarking" || a.type === "Bookmark"; 93 - return a.motivation === filter; 94 - }) 104 + if (filter === "commenting") 105 + return a.motivation === "commenting" || a.type === "Annotation"; 106 + if (filter === "highlighting") 107 + return a.motivation === "highlighting" || a.type === "Highlight"; 108 + if (filter === "bookmarking") 109 + return a.motivation === "bookmarking" || a.type === "Bookmark"; 110 + return a.motivation === filter; 111 + }) 95 112 : annotations; 96 113 97 114 return ( ··· 132 149 )} 133 150 </div> 134 151 135 - {} 152 + {showIosBanner && ( 153 + <div 154 + className="ios-banner" 155 + style={{ 156 + background: "var(--bg-secondary)", 157 + border: "1px solid var(--border)", 158 + borderRadius: "var(--radius-md)", 159 + padding: "12px", 160 + marginBottom: "20px", 161 + display: "flex", 162 + alignItems: "center", 163 + justifyContent: "space-between", 164 + gap: "12px", 165 + }} 166 + > 167 + <div style={{ flex: 1 }}> 168 + <h3 169 + style={{ 170 + fontSize: "0.9rem", 171 + fontWeight: 600, 172 + marginBottom: "4px", 173 + }} 174 + > 175 + Get the iOS Shortcut 176 + </h3> 177 + <p style={{ fontSize: "0.8rem", color: "var(--text-secondary)" }}> 178 + Easily save links from Safari using our new shortcut. 179 + </p> 180 + </div> 181 + <div style={{ display: "flex", gap: "8px", alignItems: "center" }}> 182 + <a 183 + href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 184 + target="_blank" 185 + rel="noopener noreferrer" 186 + className="btn btn-primary btn-sm" 187 + style={{ whiteSpace: "nowrap" }} 188 + > 189 + Get It 190 + </a> 191 + <button 192 + className="btn btn-sm" 193 + onClick={dismissIosBanner} 194 + style={{ 195 + color: "var(--text-tertiary)", 196 + padding: "4px", 197 + height: "auto", 198 + }} 199 + > 200 + 201 + </button> 202 + </div> 203 + </div> 204 + )} 205 + 206 + { } 136 207 <div 137 208 className="feed-filters" 138 209 style={{
+30 -9
web/src/pages/Profile.jsx
··· 62 62 } 63 63 64 64 export default function Profile() { 65 - const { handle } = useParams(); 66 - const { user } = useAuth(); 65 + const { handle: routeHandle } = useParams(); 66 + const { user, loading: authLoading } = useAuth(); 67 67 const [activeTab, setActiveTab] = useState("annotations"); 68 68 const [profile, setProfile] = useState(null); 69 69 const [annotations, setAnnotations] = useState([]); ··· 78 78 const [error, setError] = useState(null); 79 79 const [showEditModal, setShowEditModal] = useState(false); 80 80 81 + const handle = routeHandle || user?.did || user?.handle; 81 82 const isOwnProfile = user && (user.did === handle || user.handle === handle); 82 83 84 + if (authLoading) { 85 + return ( 86 + <div className="profile-page"> 87 + <div className="feed"> 88 + {[1, 2, 3].map((i) => ( 89 + <div key={i} className="card"> 90 + <div 91 + className="skeleton skeleton-text" 92 + style={{ width: "40%" }} 93 + /> 94 + <div className="skeleton skeleton-text" /> 95 + <div 96 + className="skeleton skeleton-text" 97 + style={{ width: "60%" }} 98 + /> 99 + </div> 100 + ))} 101 + </div> 102 + </div> 103 + ); 104 + } 105 + 83 106 if (!handle) { 84 - return <Navigate to={user ? `/profile/${user.did}` : "/login"} replace />; 107 + return <Navigate to="/login" replace />; 85 108 } 86 109 87 110 useEffect(() => { ··· 418 441 Save bookmarks from Safari&apos;s share sheet. 419 442 </p> 420 443 <a 421 - href="#" 444 + href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 445 + target="_blank" 446 + rel="noopener noreferrer" 422 447 className="btn btn-primary" 423 448 style={{ 424 449 display: "inline-flex", 425 450 alignItems: "center", 426 451 gap: "0.5rem", 427 - opacity: 0.5, 428 - pointerEvents: "none", 429 - cursor: "default", 430 452 }} 431 - onClick={(e) => e.preventDefault()} 432 453 > 433 - <AppleIcon size={16} /> Coming Soon 454 + <AppleIcon size={16} /> Get Shortcut 434 455 </a> 435 456 </div> 436 457 </div>