Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Fix deleting highlights on feed not working, and implement OG for highligts and collections

+272 -4
+257 -2
backend/internal/api/og.go
··· 144 144 return 145 145 } 146 146 147 + highlightURI := fmt.Sprintf("at://%s/at.margin.highlight/%s", did, rkey) 148 + highlight, err := h.db.GetHighlightByURI(highlightURI) 149 + if err == nil && highlight != nil { 150 + h.serveHighlightOG(w, highlight) 151 + return 152 + } 153 + 154 + collectionURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 155 + collection, err := h.db.GetCollectionByURI(collectionURI) 156 + if err == nil && collection != nil { 157 + h.serveCollectionOG(w, collection) 158 + return 159 + } 160 + 147 161 h.serveIndexHTML(w, r) 148 162 } 149 163 ··· 232 246 w.Write([]byte(htmlContent)) 233 247 } 234 248 249 + func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) { 250 + title := "Highlight on Margin" 251 + description := "" 252 + 253 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 254 + var selector struct { 255 + Exact string `json:"exact"` 256 + } 257 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 258 + description = fmt.Sprintf("\"%s\"", selector.Exact) 259 + if len(description) > 200 { 260 + description = description[:197] + "...\"" 261 + } 262 + } 263 + } 264 + 265 + if highlight.TargetTitle != nil && *highlight.TargetTitle != "" { 266 + title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle) 267 + if len(title) > 60 { 268 + title = title[:57] + "..." 269 + } 270 + } 271 + 272 + sourceDomain := "" 273 + if highlight.TargetSource != "" { 274 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 275 + sourceDomain = parsed.Host 276 + } 277 + } 278 + 279 + authorHandle := highlight.AuthorDID 280 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 281 + if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" { 282 + authorHandle = "@" + profile.Handle 283 + } 284 + 285 + if description == "" { 286 + description = fmt.Sprintf("A highlight by %s", authorHandle) 287 + if sourceDomain != "" { 288 + description += fmt.Sprintf(" on %s", sourceDomain) 289 + } 290 + } 291 + 292 + pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:])) 293 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI)) 294 + 295 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 296 + <html lang="en"> 297 + <head> 298 + <meta charset="UTF-8"> 299 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 300 + <title>%s - Margin</title> 301 + <meta name="description" content="%s"> 302 + 303 + <!-- Open Graph --> 304 + <meta property="og:type" content="article"> 305 + <meta property="og:title" content="%s"> 306 + <meta property="og:description" content="%s"> 307 + <meta property="og:url" content="%s"> 308 + <meta property="og:image" content="%s"> 309 + <meta property="og:image:width" content="1200"> 310 + <meta property="og:image:height" content="630"> 311 + <meta property="og:site_name" content="Margin"> 312 + 313 + <!-- Twitter Card --> 314 + <meta name="twitter:card" content="summary_large_image"> 315 + <meta name="twitter:title" content="%s"> 316 + <meta name="twitter:description" content="%s"> 317 + <meta name="twitter:image" content="%s"> 318 + 319 + <!-- Author --> 320 + <meta property="article:author" content="%s"> 321 + 322 + <meta http-equiv="refresh" content="0; url=%s"> 323 + </head> 324 + <body> 325 + <p>Redirecting to <a href="%s">%s</a>...</p> 326 + </body> 327 + </html>`, 328 + html.EscapeString(title), 329 + html.EscapeString(description), 330 + html.EscapeString(title), 331 + html.EscapeString(description), 332 + html.EscapeString(pageURL), 333 + html.EscapeString(ogImageURL), 334 + html.EscapeString(title), 335 + html.EscapeString(description), 336 + html.EscapeString(ogImageURL), 337 + html.EscapeString(authorHandle), 338 + html.EscapeString(pageURL), 339 + html.EscapeString(pageURL), 340 + html.EscapeString(title), 341 + ) 342 + 343 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 344 + w.Write([]byte(htmlContent)) 345 + } 346 + 347 + func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) { 348 + icon := "📁" 349 + if collection.Icon != nil && *collection.Icon != "" { 350 + icon = *collection.Icon 351 + } 352 + 353 + title := fmt.Sprintf("%s %s", icon, collection.Name) 354 + description := "" 355 + if collection.Description != nil && *collection.Description != "" { 356 + description = *collection.Description 357 + if len(description) > 200 { 358 + description = description[:197] + "..." 359 + } 360 + } 361 + 362 + authorHandle := collection.AuthorDID 363 + var avatarURL string 364 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 365 + if profile, ok := profiles[collection.AuthorDID]; ok { 366 + if profile.Handle != "" { 367 + authorHandle = "@" + profile.Handle 368 + } 369 + if profile.Avatar != "" { 370 + avatarURL = profile.Avatar 371 + } 372 + } 373 + 374 + if description == "" { 375 + description = fmt.Sprintf("A collection by %s", authorHandle) 376 + } else { 377 + description = fmt.Sprintf("By %s • %s", authorHandle, description) 378 + } 379 + 380 + pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI)) 381 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI)) 382 + 383 + _ = avatarURL 384 + 385 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 386 + <html lang="en"> 387 + <head> 388 + <meta charset="UTF-8"> 389 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 390 + <title>%s - Margin</title> 391 + <meta name="description" content="%s"> 392 + 393 + <!-- Open Graph --> 394 + <meta property="og:type" content="article"> 395 + <meta property="og:title" content="%s"> 396 + <meta property="og:description" content="%s"> 397 + <meta property="og:url" content="%s"> 398 + <meta property="og:image" content="%s"> 399 + <meta property="og:image:width" content="1200"> 400 + <meta property="og:image:height" content="630"> 401 + <meta property="og:site_name" content="Margin"> 402 + 403 + <!-- Twitter Card --> 404 + <meta name="twitter:card" content="summary_large_image"> 405 + <meta name="twitter:title" content="%s"> 406 + <meta name="twitter:description" content="%s"> 407 + <meta name="twitter:image" content="%s"> 408 + 409 + <!-- Author --> 410 + <meta property="article:author" content="%s"> 411 + 412 + <meta http-equiv="refresh" content="0; url=%s"> 413 + </head> 414 + <body> 415 + <p>Redirecting to <a href="%s">%s</a>...</p> 416 + </body> 417 + </html>`, 418 + html.EscapeString(title), 419 + html.EscapeString(description), 420 + html.EscapeString(title), 421 + html.EscapeString(description), 422 + html.EscapeString(pageURL), 423 + html.EscapeString(ogImageURL), 424 + html.EscapeString(title), 425 + html.EscapeString(description), 426 + html.EscapeString(ogImageURL), 427 + html.EscapeString(authorHandle), 428 + html.EscapeString(pageURL), 429 + html.EscapeString(pageURL), 430 + html.EscapeString(title), 431 + ) 432 + 433 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 434 + w.Write([]byte(htmlContent)) 435 + } 436 + 235 437 func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 236 438 title := "Annotation on Margin" 237 439 description := "" ··· 417 619 } 418 620 } 419 621 } else { 420 - http.Error(w, "Record not found", http.StatusNotFound) 421 - return 622 + highlight, err := h.db.GetHighlightByURI(uri) 623 + if err == nil && highlight != nil { 624 + authorHandle = highlight.AuthorDID 625 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 626 + if profile, ok := profiles[highlight.AuthorDID]; ok { 627 + if profile.Handle != "" { 628 + authorHandle = "@" + profile.Handle 629 + } 630 + if profile.Avatar != "" { 631 + avatarURL = profile.Avatar 632 + } 633 + } 634 + 635 + text = "Highlight" 636 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 637 + var selector struct { 638 + Exact string `json:"exact"` 639 + } 640 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 641 + quote = selector.Exact 642 + } 643 + } 644 + 645 + if highlight.TargetSource != "" { 646 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 647 + sourceDomain = parsed.Host 648 + } 649 + } 650 + } else { 651 + collection, err := h.db.GetCollectionByURI(uri) 652 + if err == nil && collection != nil { 653 + authorHandle = collection.AuthorDID 654 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 655 + if profile, ok := profiles[collection.AuthorDID]; ok { 656 + if profile.Handle != "" { 657 + authorHandle = "@" + profile.Handle 658 + } 659 + if profile.Avatar != "" { 660 + avatarURL = profile.Avatar 661 + } 662 + } 663 + 664 + icon := "📁" 665 + if collection.Icon != nil && *collection.Icon != "" { 666 + icon = *collection.Icon 667 + } 668 + text = fmt.Sprintf("%s %s", icon, collection.Name) 669 + if collection.Description != nil && *collection.Description != "" { 670 + quote = *collection.Description 671 + } 672 + } else { 673 + http.Error(w, "Record not found", http.StatusNotFound) 674 + return 675 + } 676 + } 422 677 } 423 678 } 424 679
+1
web/src/components/AnnotationCard.jsx
··· 736 736 > 737 737 <HighlightIcon size={14} /> Highlight 738 738 </span> 739 + <ShareMenu uri={data.uri} text={highlightedText} /> 739 740 <button 740 741 className="annotation-action" 741 742 onClick={() => {
+14 -2
web/src/pages/Feed.jsx
··· 2 2 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 3 import BookmarkCard from "../components/BookmarkCard"; 4 4 import CollectionItemCard from "../components/CollectionItemCard"; 5 - import { getAnnotationFeed } from "../api/client"; 5 + import { getAnnotationFeed, deleteHighlight } from "../api/client"; 6 6 import { AlertIcon, InboxIcon } from "../components/Icons"; 7 7 8 8 export default function Feed() { ··· 129 129 item.type === "Highlight" || 130 130 item.motivation === "highlighting" 131 131 ) { 132 - return <HighlightCard key={item.id} highlight={item} />; 132 + return ( 133 + <HighlightCard 134 + key={item.id} 135 + highlight={item} 136 + onDelete={async (uri) => { 137 + const rkey = uri.split("/").pop(); 138 + await deleteHighlight(rkey); 139 + setAnnotations((prev) => 140 + prev.filter((a) => a.id !== item.id), 141 + ); 142 + }} 143 + /> 144 + ); 133 145 } 134 146 if (item.type === "Bookmark" || item.motivation === "bookmarking") { 135 147 return <BookmarkCard key={item.id} bookmark={item} />;