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

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.

+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={{