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

qol updates and fixes

+179 -97
+54 -44
backend/internal/api/notes.go
··· 983 983 req.Tags[i] = strings.ToLower(t) 984 984 } 985 985 986 + description := strings.TrimSpace(req.Description) 987 + motivation := "bookmarking" 988 + if description != "" { 989 + motivation = "commenting" 990 + } 991 + 986 992 urlHash := db.HashURL(req.URL) 987 - record := xrpc.NewNoteRecord(req.URL, urlHash, "", nil, req.Title, "", req.Description, "bookmarking") 993 + record := xrpc.NewNoteRecord(req.URL, urlHash, description, nil, req.Title, "", "", motivation) 988 994 if len(req.Tags) > 0 { 989 995 record.Tags = req.Tags 990 996 } ··· 996 1002 997 1003 var result *xrpc.CreateRecordOutput 998 1004 999 - if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { 1000 - WriteConflict(w, "Bookmark already exists") 1001 - return 1005 + if motivation == "bookmarking" { 1006 + if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { 1007 + WriteConflict(w, "Bookmark already exists") 1008 + return 1009 + } 1002 1010 } 1003 1011 1004 1012 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 1011 1019 return 1012 1020 } 1013 1021 1014 - capturedSession := session 1015 - capturedTags := append([]string(nil), req.Tags...) 1016 - capturedURL := req.URL 1017 - capturedURLHash := urlHash 1018 - capturedNoteURI := result.URI 1019 - go func() { 1020 - prefs, dbErr := s.db.GetPreferences(capturedSession.DID) 1021 - communityEnabled := dbErr == nil && prefs != nil && (prefs.EnableCommunityBookmarks == nil || *prefs.EnableCommunityBookmarks) 1022 - if !communityEnabled { 1023 - return 1024 - } 1022 + if motivation == "bookmarking" { 1023 + capturedSession := session 1024 + capturedTags := append([]string(nil), req.Tags...) 1025 + capturedURL := req.URL 1026 + capturedURLHash := urlHash 1027 + capturedNoteURI := result.URI 1028 + go func() { 1029 + prefs, dbErr := s.db.GetPreferences(capturedSession.DID) 1030 + communityEnabled := dbErr == nil && prefs != nil && (prefs.EnableCommunityBookmarks == nil || *prefs.EnableCommunityBookmarks) 1031 + if !communityEnabled { 1032 + return 1033 + } 1025 1034 1026 - tagsJSON := "" 1027 - if len(capturedTags) > 0 { 1028 - if b, err := json.Marshal(capturedTags); err == nil { 1029 - tagsJSON = string(b) 1035 + tagsJSON := "" 1036 + if len(capturedTags) > 0 { 1037 + if b, err := json.Marshal(capturedTags); err == nil { 1038 + tagsJSON = string(b) 1039 + } 1030 1040 } 1031 - } 1032 - if exists, err := s.db.CommunityBookmarkExists(capturedSession.DID, capturedURLHash, tagsJSON); err == nil && exists { 1033 - return 1034 - } 1041 + if exists, err := s.db.CommunityBookmarkExists(capturedSession.DID, capturedURLHash, tagsJSON); err == nil && exists { 1042 + return 1043 + } 1035 1044 1036 - client := s.refresher.CreateClientFromSession(capturedSession) 1037 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1038 - defer cancel() 1039 - communityRecord := map[string]interface{}{ 1040 - "$type": xrpc.CollectionCommunityBookmark, 1041 - "subject": capturedURL, 1042 - "createdAt": time.Now().UTC().Format(time.RFC3339), 1043 - } 1044 - if len(capturedTags) > 0 { 1045 - communityRecord["tags"] = capturedTags 1046 - } 1047 - communityResult, communityErr := client.CreateRecord(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, communityRecord) 1048 - if communityErr == nil && communityResult != nil { 1049 - _ = s.db.SaveCommunityBookmarkRef(capturedNoteURI, communityResult.URI) 1050 - } 1051 - }() 1045 + client := s.refresher.CreateClientFromSession(capturedSession) 1046 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1047 + defer cancel() 1048 + communityRecord := map[string]interface{}{ 1049 + "$type": xrpc.CollectionCommunityBookmark, 1050 + "subject": capturedURL, 1051 + "createdAt": time.Now().UTC().Format(time.RFC3339), 1052 + } 1053 + if len(capturedTags) > 0 { 1054 + communityRecord["tags"] = capturedTags 1055 + } 1056 + communityResult, communityErr := client.CreateRecord(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, communityRecord) 1057 + if communityErr == nil && communityResult != nil { 1058 + _ = s.db.SaveCommunityBookmarkRef(capturedNoteURI, communityResult.URI) 1059 + } 1060 + }() 1061 + } 1052 1062 1053 1063 var titlePtr *string 1054 1064 if req.Title != "" { 1055 1065 titlePtr = &req.Title 1056 1066 } 1057 - var descPtr *string 1058 - if req.Description != "" { 1059 - descPtr = &req.Description 1067 + var bodyPtr *string 1068 + if description != "" { 1069 + bodyPtr = &description 1060 1070 } 1061 1071 1062 1072 var tagsJSONPtr *string ··· 1070 1080 note := &db.Note{ 1071 1081 URI: result.URI, 1072 1082 AuthorDID: session.DID, 1073 - Motivation: "bookmarking", 1083 + Motivation: motivation, 1074 1084 TargetSource: req.URL, 1075 1085 TargetHash: urlHash, 1076 1086 TargetTitle: titlePtr, 1077 - BodyValue: descPtr, 1087 + BodyValue: bodyPtr, 1078 1088 TagsJSON: tagsJSONPtr, 1079 1089 CreatedAt: time.Now(), 1080 1090 IndexedAt: time.Now(),
+49 -7
web/src/components/feed/Composer.tsx
··· 7 7 getTrendingTags, 8 8 } from "../../api/client"; 9 9 import type { Selector, ContentLabelValue } from "../../types"; 10 - import { X, ShieldAlert } from "lucide-react"; 10 + import { X, ShieldAlert, Highlighter, PenLine } from "lucide-react"; 11 11 import TagInput from "../ui/TagInput"; 12 12 import { analytics } from "../../lib/analytics"; 13 13 ··· 69 69 const highlightedText = 70 70 selector?.type === "TextQuoteSelector" ? selector.exact : null; 71 71 72 + const hasQuote = !!(highlightedText || quoteText.trim()); 73 + const hasText = !!text.trim(); 74 + const mode: "highlight" | "annotation" | "note" = 75 + hasQuote && !hasText ? "highlight" : hasQuote ? "annotation" : "note"; 76 + 77 + const modeCopy = { 78 + highlight: { 79 + title: "New highlight", 80 + icon: Highlighter, 81 + submit: "Save highlight", 82 + hint: "Saving a passage without a comment. Add text below to turn it into an annotation.", 83 + }, 84 + annotation: { 85 + title: "New annotation", 86 + icon: PenLine, 87 + submit: "Post annotation", 88 + hint: null, 89 + }, 90 + note: { 91 + title: "New note", 92 + icon: PenLine, 93 + submit: "Post note", 94 + hint: null, 95 + }, 96 + }[mode]; 97 + const ModeIcon = modeCopy.icon; 98 + 72 99 const handleSubmit = async (e: React.FormEvent) => { 73 100 e.preventDefault(); 74 101 if (!text.trim() && !highlightedText && !quoteText.trim()) return; ··· 103 130 analytics.capture("highlight_created", { 104 131 url, 105 132 tag_count: tagList.length, 133 + has_color: true, 106 134 has_labels: selfLabels.length > 0, 107 135 }); 108 136 } else { ··· 146 174 return ( 147 175 <form onSubmit={handleSubmit} className="flex flex-col gap-4"> 148 176 <div className="flex items-center justify-between"> 149 - <h3 className="text-lg font-bold text-surface-900 dark:text-white"> 150 - New Annotation 177 + <h3 className="flex items-center gap-2 text-lg font-bold text-surface-900 dark:text-white"> 178 + <ModeIcon 179 + size={18} 180 + className={ 181 + mode === "highlight" 182 + ? "text-amber-500" 183 + : "text-primary-500 dark:text-primary-400" 184 + } 185 + /> 186 + {modeCopy.title} 151 187 </h3> 152 188 {url && ( 153 189 <div className="text-xs text-surface-400 dark:text-surface-500 max-w-[200px] truncate"> ··· 156 192 )} 157 193 </div> 158 194 195 + {modeCopy.hint && ( 196 + <div className="text-xs text-surface-500 dark:text-surface-400 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-900/30 rounded-lg px-3 py-2"> 197 + {modeCopy.hint} 198 + </div> 199 + )} 200 + 159 201 {highlightedText && ( 160 202 <div className="relative p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg"> 161 203 <button ··· 208 250 value={text} 209 251 onChange={(e) => setText(e.target.value)} 210 252 placeholder={ 211 - highlightedText || quoteText 212 - ? "Add your comment..." 213 - : "Write your annotation..." 253 + hasQuote 254 + ? "Add your thoughts on this passage..." 255 + : "What's on your mind?" 214 256 } 215 257 className="w-full p-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none min-h-[100px] resize-none" 216 258 maxLength={3000} ··· 292 334 loading || (!text.trim() && !highlightedText && !quoteText.trim()) 293 335 } 294 336 > 295 - {loading ? "..." : "Post"} 337 + {loading ? "..." : modeCopy.submit} 296 338 </button> 297 339 </div> 298 340 </div>
+2 -2
web/src/components/feed/MasonryFeed.tsx
··· 15 15 16 16 export default function MasonryFeed({ 17 17 motivation, 18 - emptyMessage = "No items found.", 18 + emptyMessage = "You haven't saved anything here yet.", 19 19 showTabs = false, 20 20 title, 21 21 }: MasonryFeedProps) { ··· 75 75 emptyMessage={ 76 76 activeTab === "my" 77 77 ? emptyMessage 78 - : `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.` 78 + : `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet — be the first.` 79 79 } 80 80 creator={creator} 81 81 layout={layout}
+22 -19
web/src/components/modals/SignUpModal.tsx
··· 25 25 name: "Margin", 26 26 service: "https://margin.cafe", 27 27 Icon: MarginIcon, 28 - description: "Hosted by Margin, the easiest way to get started", 28 + description: "The easiest way to get started", 29 29 }; 30 30 31 31 const OTHER_PROVIDERS: Provider[] = [ ··· 34 34 name: "Bluesky", 35 35 service: "https://bsky.social", 36 36 Icon: BlueskyIcon, 37 - description: "The most popular option on the AT Protocol", 37 + description: "The largest and most popular community", 38 38 }, 39 39 { 40 40 id: "blacksky", 41 41 name: "Blacksky", 42 42 service: "https://blacksky.app", 43 43 Icon: BlackskyIcon, 44 - description: "For the Culture. A safe space for users and allies", 44 + description: "For the Culture — a safe space for users and allies", 45 + }, 46 + { 47 + id: "eurosky", 48 + name: "Eurosky", 49 + service: "https://eurosky.social", 50 + Icon: null, 51 + description: "Eurosky is your European home on the Atmosphere", 45 52 }, 46 53 { 47 54 id: "selfhosted.social", 48 55 name: "selfhosted.social", 49 56 service: "https://selfhosted.social", 50 57 Icon: null, 51 - description: "For hackers, designers, and ATProto enthusiasts.", 58 + description: "A home for builders, tinkerers, and the curious", 52 59 }, 53 60 { 54 61 id: "northsky", 55 62 name: "Northsky", 56 63 service: "https://northsky.social", 57 64 Icon: NorthskyIcon, 58 - description: "A Canadian-based worker-owned cooperative", 65 + description: "A Canadian worker-owned cooperative", 59 66 }, 60 67 { 61 68 id: "tophhie", ··· 65 72 description: "A welcoming and friendly community", 66 73 }, 67 74 { 68 - id: "altq", 69 - name: "AltQ", 70 - service: "https://altq.net", 71 - Icon: null, 72 - description: "An independent, self-hosted PDS instance", 73 - }, 74 - { 75 75 id: "custom", 76 - name: "Custom", 76 + name: "Use a custom PDS", 77 77 service: "", 78 78 custom: true, 79 79 Icon: null, 80 - description: "Connect to your own or another custom PDS", 80 + description: "Already have a PDS? Enter its address.", 81 81 }, 82 82 ]; 83 83 ··· 207 207 } catch (err) { 208 208 console.error(err); 209 209 analytics.captureException(err); 210 - setError("Could not connect to this PDS. Please check the URL."); 210 + setError("Couldn't connect to that PDS. Double-check the address."); 211 211 setLoading(false); 212 212 } 213 213 }; ··· 232 232 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4" 233 233 /> 234 234 <p className="text-surface-600 dark:text-surface-400 font-medium"> 235 - Connecting to provider... 235 + Connecting... 236 236 </p> 237 237 </div> 238 238 ) : showCustomInput ? ( 239 239 <div> 240 - <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-6"> 241 - Custom Provider 240 + <h2 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-2"> 241 + Use a custom PDS 242 242 </h2> 243 + <p className="text-sm text-surface-500 dark:text-surface-400 mb-6"> 244 + Enter the address of the PDS hosting your account. 245 + </p> 243 246 <form onSubmit={handleCustomSubmit} className="space-y-4"> 244 247 <div> 245 248 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 246 - PDS address (e.g. pds.example.com) 249 + PDS address 247 250 </label> 248 251 <input 249 252 type="text"
+10 -5
web/src/components/ui/EmptyState.tsx
··· 19 19 return ( 20 20 <div 21 21 className={clsx( 22 - "text-center py-16 px-6", 23 - "bg-surface-50/50 dark:bg-surface-800/50 rounded-2xl", 22 + "relative text-center py-14 px-6 overflow-hidden", 23 + "bg-gradient-to-b from-surface-50/80 to-surface-50/20 dark:from-surface-800/60 dark:to-surface-800/20 rounded-2xl", 24 24 "border border-dashed border-surface-200 dark:border-surface-700", 25 25 className, 26 26 )} 27 27 > 28 28 {icon && ( 29 - <div className="flex justify-center mb-4 text-surface-300 dark:text-surface-600"> 30 - {icon} 29 + <div className="relative flex justify-center mb-4"> 30 + <div className="absolute inset-0 flex justify-center items-center"> 31 + <div className="h-16 w-16 rounded-full bg-primary-100/60 dark:bg-primary-900/20 blur-xl" /> 32 + </div> 33 + <div className="relative text-surface-400 dark:text-surface-500"> 34 + {icon} 35 + </div> 31 36 </div> 32 37 )} 33 38 {title && ( ··· 35 40 {title} 36 41 </h3> 37 42 )} 38 - <p className="text-surface-500 dark:text-surface-400 max-w-sm mx-auto"> 43 + <p className="text-surface-500 dark:text-surface-400 max-w-sm mx-auto leading-relaxed"> 39 44 {message} 40 45 </p> 41 46 {action && (
+15
web/src/views/AppShell.tsx
··· 35 35 import Collections from "./collections/Collections"; 36 36 import CollectionDetail from "./collections/CollectionDetail"; 37 37 import AnnotationDetail from "./content/AnnotationDetail"; 38 + import UrlPage from "./content/UrlPage"; 39 + import UserUrlPage from "./content/UserUrlPage"; 38 40 import Profile from "./profile/Profile"; 39 41 40 42 const PAGE_TITLES: Record<string, string> = { ··· 104 106 const { did } = useParams<{ did: string }>(); 105 107 if (!did) return <Navigate to="/home" replace />; 106 108 return <Profile did={did} />; 109 + } 110 + 111 + function UrlRoute() { 112 + const params = useParams(); 113 + const urlPath = params["*"]; 114 + return <UrlPage urlPath={urlPath} />; 115 + } 116 + 117 + function UserUrlRoute() { 118 + const params = useParams(); 119 + return <UserUrlPage handle={params.handle} urlPath={params["*"]} />; 107 120 } 108 121 109 122 function AppLayout() { ··· 276 289 element={<UriAnnotationRoute />} 277 290 /> 278 291 <Route path="/at/:did/:rkey" element={<AtAnnotationRoute />} /> 292 + <Route path="/url/*" element={<UrlRoute />} /> 293 + <Route path="/:handle/url/*" element={<UserUrlRoute />} /> 279 294 <Route path="/profile/:did" element={<ProfileRoute />} /> 280 295 <Route 281 296 path="/profile"
+6 -3
web/src/views/auth/Login.tsx
··· 32 32 const [providerIndex, setProviderIndex] = useState(0); 33 33 const [morphClass, setMorphClass] = useState( 34 34 "opacity-100 translate-y-0 blur-0", 35 - ); 35 + ); 36 36 const providers = [ 37 37 "AT Protocol", 38 38 "Margin", ··· 155 155 }; 156 156 157 157 return ( 158 - <div className="min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4"> 159 - <div className="w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none"> 158 + <div className="relative min-h-screen flex items-center justify-center bg-surface-100 dark:bg-surface-800 p-4 overflow-hidden"> 159 + <div className="pointer-events-none absolute inset-0 -z-0"> 160 + <div className="absolute top-1/4 left-1/2 -translate-x-1/2 h-96 w-96 rounded-full bg-primary-200/30 dark:bg-primary-900/20 blur-3xl" /> 161 + </div> 162 + <div className="relative w-full max-w-[440px] bg-white dark:bg-surface-900 rounded-2xl border border-surface-200/60 dark:border-surface-800 p-8 shadow-sm dark:shadow-none"> 160 163 <div className="flex flex-col items-center mb-8"> 161 164 <h1 className="text-2xl font-bold font-display text-surface-900 dark:text-white text-center leading-snug"> 162 165 Sign in with your <br />
+6 -6
web/src/views/content/UrlPage.tsx
··· 281 281 {!loading && !error && totalItems === 0 && ( 282 282 <EmptyState 283 283 icon={<Search size={48} />} 284 - title="No annotations yet" 285 - message="Nobody has annotated this page yet. Be the first — install the Margin extension and start annotating!" 284 + title="This page is a blank canvas" 285 + message="No one's left notes here yet. Want to be the first? Grab the Margin extension and share what you're thinking." 286 286 /> 287 287 )} 288 288 ··· 306 306 {activeTab === "annotations" && annotations.length === 0 && ( 307 307 <EmptyState 308 308 icon={<PenTool size={32} />} 309 - title="No annotations" 310 - message="There are no annotations for this page yet." 309 + title="No annotations yet" 310 + message="Nobody has left a written note on this page." 311 311 /> 312 312 )} 313 313 {activeTab === "highlights" && highlights.length === 0 && ( 314 314 <EmptyState 315 315 icon={<Highlighter size={32} />} 316 - title="No highlights" 317 - message="There are no highlights for this page yet." 316 + title="No highlights yet" 317 + message="Nobody has highlighted a passage from this page." 318 318 /> 319 319 )} 320 320
+11 -7
web/src/views/core/Feed.tsx
··· 32 32 initialUser, 33 33 motivation, 34 34 showTabs = true, 35 - emptyMessage = "No items found.", 35 + emptyMessage = "Nothing here yet — annotations from you and people you follow will show up here.", 36 36 initialItems, 37 37 initialHasMore, 38 38 }: FeedProps) { ··· 91 91 return ( 92 92 <div className="mx-auto max-w-2xl xl:max-w-none"> 93 93 {!user && ( 94 - <div className="text-center py-10 px-6 mb-4 animate-fade-in"> 95 - <h1 className="text-2xl font-display font-bold mb-2 tracking-tight text-surface-900 dark:text-white"> 94 + <div className="relative text-center py-12 px-6 mb-4 animate-fade-in overflow-hidden"> 95 + <div className="absolute inset-0 -z-10 flex items-center justify-center"> 96 + <div className="h-48 w-48 rounded-full bg-primary-200/40 dark:bg-primary-900/20 blur-3xl" /> 97 + </div> 98 + <h1 className="text-3xl sm:text-4xl font-display font-bold mb-3 tracking-tight text-surface-900 dark:text-white"> 96 99 Welcome to Margin 97 100 </h1> 98 - <p className="text-surface-500 dark:text-surface-400 mb-4 max-w-md mx-auto"> 99 - Annotate, highlight, and bookmark anything on the web. 101 + <p className="text-surface-500 dark:text-surface-400 mb-5 max-w-md mx-auto leading-relaxed"> 102 + A quiet place to annotate, highlight, and save what you read on the 103 + web. 100 104 </p> 101 105 <div className="flex gap-3 justify-center"> 102 106 <Button onClick={() => (window.location.href = "/login")}> 103 - Get Started 107 + Get started 104 108 </Button> 105 109 <Button 106 110 variant="secondary" 107 111 onClick={() => window.open("/about", "_blank")} 108 112 > 109 - Learn More 113 + Learn more 110 114 </Button> 111 115 </div> 112 116 </div>
+2 -2
web/src/views/core/New.tsx
··· 68 68 <div className="max-w-2xl mx-auto pb-20"> 69 69 <div className="mb-6 text-center sm:text-left"> 70 70 <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-1"> 71 - New Annotation 71 + Compose 72 72 </h1> 73 73 <p className="text-surface-500 dark:text-surface-400"> 74 - Write in the margins of the web 74 + Highlight a passage, leave a note, or annotate a page — all from here. 75 75 </p> 76 76 </div> 77 77
+2 -2
web/src/views/profile/Profile.tsx
··· 700 700 layout="list" 701 701 emptyMessage={ 702 702 isOwner 703 - ? `You haven't added any ${activeTab} yet.` 704 - : `No ${activeTab}` 703 + ? `Your ${activeTab} will show up here.` 704 + : `Nothing to see here yet.` 705 705 } 706 706 /> 707 707 )}