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

Configure Feed

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

render handles and links in annotation cards and when there is a quote in semble annotation, make it show it as a margin quote in the card.

scanash00 a71a8a08 9870ecb9

+237 -14
+74
backend/internal/api/annotations.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "regexp" 8 9 "strings" 9 10 "time" 10 11 ··· 71 72 motivation = "tagging" 72 73 } 73 74 75 + var facets []xrpc.Facet 76 + var mentionedDIDs []string 77 + 78 + mentionRegex := regexp.MustCompile(`(^|\s|@)@([a-zA-Z0-9.-]+)(\b)`) 79 + matches := mentionRegex.FindAllStringSubmatchIndex(req.Text, -1) 80 + 81 + for _, m := range matches { 82 + handle := req.Text[m[4]:m[5]] 83 + 84 + if !strings.Contains(handle, ".") { 85 + continue 86 + } 87 + 88 + var did string 89 + err := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 90 + var resolveErr error 91 + did, resolveErr = client.ResolveHandle(r.Context(), handle) 92 + return resolveErr 93 + }) 94 + 95 + if err == nil && did != "" { 96 + start := m[2] 97 + end := m[5] 98 + 99 + facets = append(facets, xrpc.Facet{ 100 + Index: xrpc.FacetIndex{ 101 + ByteStart: start, 102 + ByteEnd: end, 103 + }, 104 + Features: []xrpc.FacetFeature{ 105 + { 106 + Type: "app.bsky.richtext.facet#mention", 107 + Did: did, 108 + }, 109 + }, 110 + }) 111 + mentionedDIDs = append(mentionedDIDs, did) 112 + } 113 + } 114 + 115 + urlRegex := regexp.MustCompile(`(https?://[^\s]+)`) 116 + urlMatches := urlRegex.FindAllStringIndex(req.Text, -1) 117 + 118 + for _, m := range urlMatches { 119 + facets = append(facets, xrpc.Facet{ 120 + Index: xrpc.FacetIndex{ 121 + ByteStart: m[0], 122 + ByteEnd: m[1], 123 + }, 124 + Features: []xrpc.FacetFeature{ 125 + { 126 + Type: "app.bsky.richtext.facet#link", 127 + Uri: req.Text[m[0]:m[1]], 128 + }, 129 + }, 130 + }) 131 + } 132 + 74 133 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 75 134 if len(req.Tags) > 0 { 76 135 record.Tags = req.Tags 136 + } 137 + if len(facets) > 0 { 138 + record.Facets = facets 77 139 } 78 140 79 141 var result *xrpc.CreateRecordOutput ··· 95 157 if err != nil { 96 158 http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) 97 159 return 160 + } 161 + 162 + for _, mentionedDID := range mentionedDIDs { 163 + if mentionedDID != session.DID { 164 + s.db.CreateNotification(&db.Notification{ 165 + RecipientDID: mentionedDID, 166 + ActorDID: session.DID, 167 + Type: "mention", 168 + SubjectURI: result.URI, 169 + CreatedAt: time.Now(), 170 + }) 171 + } 98 172 } 99 173 100 174 bodyValue := req.Text
+22
backend/internal/firehose/ingester.go
··· 731 731 motivation := "commenting" 732 732 bodyValue := note.Text 733 733 734 + var selectorJSONPtr *string 735 + 736 + if strings.HasPrefix(bodyValue, "\"") && strings.Contains(bodyValue, "\"\n") { 737 + parts := strings.SplitN(bodyValue, "\"\n", 2) 738 + if len(parts) == 2 { 739 + quoteText := strings.TrimPrefix(parts[0], "\"") 740 + noteText := parts[1] 741 + 742 + bodyValue = noteText 743 + motivation = "highlighting" 744 + 745 + selector := xrpc.TextQuoteSelector{ 746 + Type: xrpc.SelectorTypeQuote, 747 + Exact: quoteText, 748 + } 749 + selectorBytes, _ := json.Marshal(selector) 750 + selectorStr := string(selectorBytes) 751 + selectorJSONPtr = &selectorStr 752 + } 753 + } 754 + 734 755 annotation := &db.Annotation{ 735 756 URI: uri, 736 757 AuthorDID: event.Repo, ··· 738 759 BodyValue: &bodyValue, 739 760 TargetSource: targetSource, 740 761 TargetHash: targetHash, 762 + SelectorJSON: selectorJSONPtr, 741 763 CreatedAt: createdAt, 742 764 IndexedAt: time.Now(), 743 765 }
+30
backend/internal/xrpc/client.go
··· 276 276 277 277 return &output, nil 278 278 } 279 + 280 + type ResolveHandleOutput struct { 281 + Did string `json:"did"` 282 + } 283 + 284 + func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 285 + url := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", c.PDS, handle) 286 + 287 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 288 + if err != nil { 289 + return "", err 290 + } 291 + 292 + resp, err := http.DefaultClient.Do(req) 293 + if err != nil { 294 + return "", err 295 + } 296 + defer resp.Body.Close() 297 + 298 + if resp.StatusCode >= 400 { 299 + return "", fmt.Errorf("XRPC error %d", resp.StatusCode) 300 + } 301 + 302 + var output ResolveHandleOutput 303 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 304 + return "", err 305 + } 306 + 307 + return output.Did, nil 308 + }
+17
backend/internal/xrpc/records.go
··· 75 75 Body *AnnotationBody `json:"body,omitempty"` 76 76 Target AnnotationTarget `json:"target"` 77 77 Tags []string `json:"tags,omitempty"` 78 + Facets []Facet `json:"facets,omitempty"` 78 79 CreatedAt string `json:"createdAt"` 80 + } 81 + 82 + type Facet struct { 83 + Index FacetIndex `json:"index"` 84 + Features []FacetFeature `json:"features"` 85 + } 86 + 87 + type FacetIndex struct { 88 + ByteStart int `json:"byteStart"` 89 + ByteEnd int `json:"byteEnd"` 90 + } 91 + 92 + type FacetFeature struct { 93 + Type string `json:"$type"` 94 + Did string `json:"did,omitempty"` 95 + Uri string `json:"uri,omitempty"` 79 96 } 80 97 81 98 type AnnotationBody struct {
+2 -1
web/src/components/AnnotationCard.jsx
··· 2 2 import { useAuth } from "../context/AuthContext"; 3 3 import ReplyList from "./ReplyList"; 4 4 import { Link } from "react-router-dom"; 5 + import RichText from "./RichText"; 5 6 import { 6 7 normalizeAnnotation, 7 8 normalizeHighlight, ··· 378 379 </div> 379 380 </div> 380 381 ) : ( 381 - data.text && <p className="annotation-text">{data.text}</p> 382 + <RichText text={data.text} facets={data.facets} /> 382 383 )} 383 384 384 385 {data.tags?.length > 0 && (
+60
web/src/components/RichText.jsx
··· 1 + import React from "react"; 2 + import { Link } from "react-router-dom"; 3 + 4 + const URL_REGEX = /(https?:\/\/[^\s]+)/g; 5 + 6 + export default function RichText({ text }) { 7 + if (!text) return null; 8 + 9 + const parts = text.split(URL_REGEX); 10 + 11 + return ( 12 + <p className="annotation-text"> 13 + {parts.map((part, i) => { 14 + if (part.match(URL_REGEX)) { 15 + return ( 16 + <a 17 + key={i} 18 + href={part} 19 + target="_blank" 20 + rel="noopener noreferrer" 21 + onClick={(e) => e.stopPropagation()} 22 + className="rich-text-link" 23 + > 24 + {part} 25 + </a> 26 + ); 27 + } 28 + 29 + const subParts = part.split(/((?:^|\s)@[a-zA-Z0-9.-]+\b)/g); 30 + 31 + return ( 32 + <React.Fragment key={i}> 33 + {subParts.map((subPart, j) => { 34 + const mentionMatch = subPart.match(/^(\s*)@([a-zA-Z0-9.-]+)$/); 35 + if (mentionMatch) { 36 + const prefix = mentionMatch[1]; 37 + const handle = mentionMatch[2]; 38 + if (handle.includes(".")) { 39 + return ( 40 + <React.Fragment key={j}> 41 + {prefix} 42 + <Link 43 + to={`/profile/${handle}`} 44 + className="rich-text-mention" 45 + onClick={(e) => e.stopPropagation()} 46 + > 47 + @{handle} 48 + </Link> 49 + </React.Fragment> 50 + ); 51 + } 52 + } 53 + return subPart; 54 + })} 55 + </React.Fragment> 56 + ); 57 + })} 58 + </p> 59 + ); 60 + }
+19
web/src/css/annotations.css
··· 504 504 justify-content: flex-end; 505 505 margin-top: 12px; 506 506 } 507 + 508 + .rich-text-link { 509 + color: var(--accent); 510 + text-decoration: none; 511 + } 512 + 513 + .rich-text-link:hover { 514 + text-decoration: underline; 515 + } 516 + 517 + .rich-text-mention { 518 + color: var(--accent); 519 + font-weight: 500; 520 + text-decoration: none; 521 + } 522 + 523 + .rich-text-mention:hover { 524 + text-decoration: underline; 525 + }
+13 -13
web/src/pages/Feed.jsx
··· 94 94 95 95 const filteredAnnotations = 96 96 feedType === "all" || 97 - feedType === "popular" || 98 - feedType === "semble" || 99 - feedType === "margin" || 100 - feedType === "my-feed" 97 + feedType === "popular" || 98 + feedType === "semble" || 99 + feedType === "margin" || 100 + feedType === "my-feed" 101 101 ? filter === "all" 102 102 ? annotations 103 103 : annotations.filter((a) => { 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 - }) 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 + }) 112 112 : annotations; 113 113 114 114 return ( ··· 203 203 </div> 204 204 )} 205 205 206 - { } 206 + {} 207 207 <div 208 208 className="feed-filters" 209 209 style={{