A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

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

Implement annotation editing and deletion

+214 -100
+1 -1
go.mod
··· 10 10 github.com/go-chi/cors v1.2.2 11 11 github.com/gorilla/websocket v1.5.3 12 12 github.com/mattn/go-sqlite3 v1.14.22 13 + gotest.tools/v3 v3.5.2 13 14 ) 14 15 15 16 require ( ··· 204 205 gopkg.in/ini.v1 v1.67.0 // indirect 205 206 gopkg.in/yaml.v2 v2.4.0 // indirect 206 207 gopkg.in/yaml.v3 v3.0.1 // indirect 207 - gotest.tools/v3 v3.5.2 // indirect 208 208 honnef.co/go/tools v0.6.1 // indirect 209 209 mvdan.cc/gofumpt v0.7.0 // indirect 210 210 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
+3 -2
internal/atproto/firehose.go
··· 41 41 "at.glean.subscription": true, 42 42 "at.glean.annotation": true, 43 43 "at.glean.like": true, 44 + // TODO: support at.margin.annotation as well (ref: https://tangled.org/did:plc:rgvlxa3ecwx3bfyzlrzrwtrs/issues/1) 44 45 }, 45 46 } 46 47 } ··· 127 128 128 129 func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) { 129 130 var commit struct { 130 - Did string `json:"did"` 131 - Ops []struct { 131 + Did string `json:"did"` 132 + Ops []struct { 132 133 Action string `json:"action"` 133 134 Path string `json:"path"` 134 135 CID json.RawMessage `json:"cid"`
+1 -1
internal/atproto/firehose_handler.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "database/sql" 6 + "encoding/json" 7 7 "log/slog" 8 8 "time" 9 9
+4 -4
internal/atproto/lexicon.go
··· 51 51 } 52 52 53 53 type FeedListEntry struct { 54 - DID string `json:"did"` 55 - SubscriptionCount int `json:"subscriptionCount"` 54 + DID string `json:"did"` 55 + SubscriptionCount int `json:"subscriptionCount"` 56 56 Subscriptions []SubscriptionRecord `json:"subscriptions"` 57 57 } 58 58 59 59 type ListFeedListsResponse struct { 60 - Cursor string `json:"cursor,omitempty"` 60 + Cursor string `json:"cursor,omitempty"` 61 61 Feeds []FeedListEntry `json:"feeds"` 62 62 } 63 63 ··· 124 124 } 125 125 126 126 type GetRecommendationsResponse struct { 127 - Feeds []RecommendedFeed `json:"feeds"` 127 + Feeds []RecommendedFeed `json:"feeds"` 128 128 People []RecommendedPerson `json:"people"` 129 129 } 130 130
+20 -20
internal/db/article.go
··· 7 7 ) 8 8 9 9 type Article struct { 10 - ID int64 11 - FeedURL string 12 - FeedTitle string 13 - GUID string 14 - Title string 15 - URL sql.NullString 16 - Author sql.NullString 17 - Summary sql.NullString 18 - Content sql.NullString 19 - Published sql.NullTime 20 - Updated sql.NullTime 21 - FetchedAt sql.NullTime 22 - IsRead sql.NullBool 23 - IsStarred sql.NullBool 10 + ID int64 11 + FeedURL string 12 + FeedTitle string 13 + GUID string 14 + Title string 15 + URL sql.NullString 16 + Author sql.NullString 17 + Summary sql.NullString 18 + Content sql.NullString 19 + Published sql.NullTime 20 + Updated sql.NullTime 21 + FetchedAt sql.NullTime 22 + IsRead sql.NullBool 23 + IsStarred sql.NullBool 24 24 } 25 25 26 26 type ReadState struct { 27 - UserDID string 28 - ArticleID int64 29 - IsRead bool 30 - ReadAt sql.NullTime 31 - IsStarred bool 32 - StarredAt sql.NullTime 27 + UserDID string 28 + ArticleID int64 29 + IsRead bool 30 + ReadAt sql.NullTime 31 + IsStarred bool 32 + StarredAt sql.NullTime 33 33 } 34 34 35 35 func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) {
+14 -14
internal/db/cluster.go
··· 227 227 return nil, err 228 228 } 229 229 results = append(results, map[string]any{ 230 - "feed_url": feedURL, 231 - "score": score, 232 - "title": title, 233 - "site_url": siteURL, 234 - "description": description, 230 + "feed_url": feedURL, 231 + "score": score, 232 + "title": title, 233 + "site_url": siteURL, 234 + "description": description, 235 235 "subscriber_count": subCount, 236 236 }) 237 237 } ··· 306 306 } 307 307 308 308 type ArticleRecommendation struct { 309 - ArticleID int64 310 - Title string 311 - URL string 312 - FeedURL string 313 - FeedTitle string 314 - Author string 315 - Summary string 316 - Published sql.NullTime 317 - Score float64 309 + ArticleID int64 310 + Title string 311 + URL string 312 + FeedURL string 313 + FeedTitle string 314 + Author string 315 + Summary string 316 + Published sql.NullTime 317 + Score float64 318 318 } 319 319 320 320 func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) {
+1 -1
internal/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + _ "github.com/mattn/go-sqlite3" 5 6 "strings" 6 7 "time" 7 - _ "github.com/mattn/go-sqlite3" 8 8 ) 9 9 10 10 func NullStr(s string) sql.NullString {
+26 -11
internal/db/social.go
··· 8 8 ) 9 9 10 10 type Annotation struct { 11 - ID int64 12 - URI string 13 - AuthorDID string 11 + ID int64 12 + URI string 13 + AuthorDID string 14 14 AuthorHandle string 15 - FeedURL string 16 - ArticleURL string 17 - Quote sql.NullString 18 - Note sql.NullString 19 - Tags sql.NullString 20 - Rating sql.NullInt64 21 - CreatedAt sql.NullTime 22 - CID sql.NullString 15 + FeedURL string 16 + ArticleURL string 17 + Quote sql.NullString 18 + Note sql.NullString 19 + Tags sql.NullString 20 + Rating sql.NullInt64 21 + CreatedAt sql.NullTime 22 + CID sql.NullString 23 23 } 24 24 25 25 type Like struct { ··· 38 38 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 39 39 `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID) 40 40 return err 41 + } 42 + 43 + func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) { 44 + a := &Annotation{} 45 + err := db.QueryRowContext(ctx, ` 46 + SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 47 + FROM annotations a 48 + LEFT JOIN users u ON a.author_did = u.did 49 + WHERE a.id = ? 50 + `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, 51 + &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID) 52 + if err != nil { 53 + return nil, err 54 + } 55 + return a, nil 41 56 } 42 57 43 58 func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error {
+7 -7
internal/feed/parser.go
··· 84 84 HomePageURL string `json:"home_page_url"` 85 85 Description string `json:"description"` 86 86 Items []struct { 87 - ID string `json:"id"` 88 - URL string `json:"url"` 89 - Title string `json:"title"` 90 - ContentHTML string `json:"content_html"` 91 - ContentText string `json:"content_text"` 92 - Summary string `json:"summary"` 93 - Author struct { 87 + ID string `json:"id"` 88 + URL string `json:"url"` 89 + Title string `json:"title"` 90 + ContentHTML string `json:"content_html"` 91 + ContentText string `json:"content_text"` 92 + Summary string `json:"summary"` 93 + Author struct { 94 94 Name string `json:"name"` 95 95 } `json:"author"` 96 96 DatePublished string `json:"date_published"`
+46 -5
internal/server/annotations_handler.go
··· 7 7 "strconv" 8 8 "time" 9 9 10 + "github.com/go-chi/chi/v5" 11 + 10 12 "pkg.rbrt.fr/glean/internal/atproto" 11 13 "pkg.rbrt.fr/glean/internal/db" 12 14 ) ··· 51 53 } 52 54 53 55 s.render(w, r, "library.html", map[string]any{ 54 - "User": user, 55 - "Articles": articles, 56 - "Annotations": annotations, 57 - "LikedPage": likedPage, 58 - "AnnotPage": annotPage, 56 + "User": user, 57 + "CurrentUserDID": user.DID, 58 + "Articles": articles, 59 + "Annotations": annotations, 60 + "LikedPage": likedPage, 61 + "AnnotPage": annotPage, 59 62 }) 60 63 } 61 64 ··· 105 108 106 109 w.WriteHeader(http.StatusNoContent) 107 110 } 111 + 112 + func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) { 113 + user := currentUser(r) 114 + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) 115 + if err != nil { 116 + http.Error(w, "invalid id", http.StatusBadRequest) 117 + return 118 + } 119 + 120 + annotation, err := s.db.GetAnnotation(r.Context(), id) 121 + if err != nil { 122 + http.Error(w, "annotation not found", http.StatusNotFound) 123 + return 124 + } 125 + 126 + if annotation.AuthorDID != user.DID { 127 + http.Error(w, "forbidden", http.StatusForbidden) 128 + return 129 + } 130 + 131 + if annotation.URI != "" { 132 + if client := s.pdsClientForUser(r); client != nil { 133 + parsed, ok := atproto.ParseRecordURI(annotation.URI) 134 + if ok { 135 + if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil { 136 + s.logger.Error("failed to delete annotation from PDS", "error", delErr) 137 + } 138 + } 139 + } 140 + } 141 + 142 + if err := s.db.DeleteAnnotation(r.Context(), annotation.URI); err != nil { 143 + http.Error(w, err.Error(), http.StatusInternalServerError) 144 + return 145 + } 146 + 147 + w.WriteHeader(http.StatusOK) 148 + }
+8 -7
internal/server/articles_handler.go
··· 91 91 feed, _ := s.db.GetFeed(r.Context(), article.FeedURL) 92 92 93 93 s.render(w, r, "article_detail.html", map[string]any{ 94 - "User": user, 95 - "Article": article, 96 - "Feed": feed, 97 - "ReadState": readState, 98 - "LikeCount": likeCount, 99 - "HasLiked": liked, 100 - "Annotations": annotations, 94 + "User": user, 95 + "CurrentUserDID": user.DID, 96 + "Article": article, 97 + "Feed": feed, 98 + "ReadState": readState, 99 + "LikeCount": likeCount, 100 + "HasLiked": liked, 101 + "Annotations": annotations, 101 102 }) 102 103 } 103 104
+7 -4
internal/server/profile_handler.go
··· 18 18 annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0) 19 19 subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) 20 20 21 + user := currentUser(r) 22 + 21 23 s.render(w, r, "profile.html", map[string]any{ 22 - "User": currentUser(r), 24 + "User": user, 25 + "CurrentUserDID": user.DID, 23 26 "ProfileUser": profileUser, 24 - "Subscriptions": subs, 25 - "Annotations": annotations, 27 + "Subscriptions": subs, 28 + "Annotations": annotations, 26 29 "SubscriptionCount": subCount, 27 - "AnnotationCount": len(annotations), 30 + "AnnotationCount": len(annotations), 28 31 }) 29 32 }
+15
internal/server/server.go
··· 135 135 r.Use(s.requireAuth) 136 136 r.Get("/", s.handleLibrary) 137 137 r.Post("/create", s.handleCreateAnnotation) 138 + r.Post("/{id}/delete", s.handleDeleteAnnotation) 138 139 }) 139 140 140 141 s.router.Get("/auth/login", s.handleAuthLogin) ··· 156 157 157 158 func (s *Server) loadTemplates() { 158 159 fm := template.FuncMap{ 160 + "dict": func(values ...any) (map[string]any, error) { 161 + if len(values)%2 != 0 { 162 + return nil, fmt.Errorf("dict requires even number of arguments") 163 + } 164 + m := make(map[string]any, len(values)/2) 165 + for i := 0; i < len(values); i += 2 { 166 + key, ok := values[i].(string) 167 + if !ok { 168 + return nil, fmt.Errorf("dict key must be string") 169 + } 170 + m[key] = values[i+1] 171 + } 172 + return m, nil 173 + }, 159 174 "formatDate": func(t time.Time) string { 160 175 return t.Format("Jan 02, 2006") 161 176 },
+1 -1
internal/tmpl/article_detail.html
··· 99 99 100 100 <div id="annotations-list" class="space-y-3"> 101 101 {{range .Annotations}} 102 - {{template "annotation-card.html" .}} 102 + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} 103 103 {{else}} 104 104 <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p> 105 105 {{end}}
+30
internal/tmpl/base.html
··· 262 262 (quote ? form.querySelector('input[name="note"]') : quoteInput).focus(); 263 263 } 264 264 265 + function editAnnotation(btn, id, feedURL, articleURL, quote, note, tags) { 266 + var card = btn.closest('[id^="annotation-"]'); 267 + card.innerHTML = '<form hx-post="/library/create" hx-swap="none" hx-on::after-request="editAnnotationDone(' + id + ')" class="space-y-3">' + 268 + '<input type="hidden" name="feed_url" value="' + feedURL + '">' + 269 + '<input type="hidden" name="article_url" value="' + articleURL + '">' + 270 + '<input type="text" name="quote" value="' + (quote || '').replace(/"/g, '&quot;') + '" placeholder="Quote a passage..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' + 271 + '<textarea name="note" rows="3" placeholder="Add a note..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none">' + (note || '') + '</textarea>' + 272 + '<input type="text" name="tags" value="' + (tags || '').replace(/"/g, '&quot;') + '" placeholder="Tags (comma separated)" class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' + 273 + '<div class="flex gap-2 justify-end">' + 274 + '<button type="button" onclick="cancelEdit(' + id + ')" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Cancel</button>' + 275 + '<button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>' + 276 + '</div></form>'; 277 + htmx.process(card); 278 + var deleteReq = new XMLHttpRequest(); 279 + deleteReq.open('POST', '/library/' + id + '/delete'); 280 + deleteReq.setRequestHeader('HX-Request', 'true'); 281 + deleteReq.send(); 282 + } 283 + 284 + function editAnnotationDone(id) { 285 + var params = new URLSearchParams(window.location.search); 286 + var url = window.location.pathname; 287 + if (params.toString()) url += '?' + params.toString(); 288 + htmx.ajax('GET', url, '#main-content'); 289 + } 290 + 291 + function cancelEdit(id) { 292 + htmx.ajax('GET', window.location.pathname + window.location.search, '#main-content'); 293 + } 294 + 265 295 document.addEventListener('mouseup', function(e) { 266 296 var sel = window.getSelection(); 267 297 var text = sel.toString().trim();
+1 -8
internal/tmpl/library.html
··· 45 45 <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> 46 46 <div id="annotations-list" class="space-y-3"> 47 47 {{range .Annotations}} 48 - <div class="bg-spot-surface rounded-xl p-4"> 49 - {{if .ArticleURL}} 50 - <div class="text-xs text-spot-muted mb-2"> 51 - <a href="{{.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.ArticleURL}}</a> 52 - </div> 53 - {{end}} 54 - {{template "annotation-card.html" .}} 55 - </div> 48 + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} 56 49 {{else}} 57 50 <div class="flex flex-col items-center justify-center py-12 text-center"> 58 51 <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
+27 -13
internal/tmpl/partials/annotation-card.html
··· 1 1 {{define "annotation-card.html"}} 2 - <div class="bg-spot-surface rounded-xl p-4"> 3 - {{if .Quote.Valid}} 4 - <blockquote class="border-l-2 border-spot-green pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote> 2 + <div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-4 shadow-spot"> 3 + {{if .annotation.ArticleURL}} 4 + <div class="text-xs text-spot-muted mb-2"> 5 + <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.annotation.ArticleURL}}</a> 6 + </div> 5 7 {{end}} 6 - {{if .Note.Valid}} 7 - <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p> 8 + {{if .annotation.Quote.Valid}} 9 + <blockquote class="border-l-2 border-spot-green pl-3 text-sm text-spot-body italic leading-relaxed">{{.annotation.Quote.String}}</blockquote> 8 10 {{end}} 9 - {{if .Tags.Valid}} 10 - <div class="flex gap-1 mt-2 flex-wrap"> 11 - {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} 11 + {{if .annotation.Note.Valid}} 12 + <p class="text-sm text-spot-body mt-2 leading-relaxed">{{.annotation.Note.String}}</p> 13 + {{end}} 14 + {{if .annotation.Tags.Valid}} 15 + <div class="flex gap-1.5 mt-3 flex-wrap"> 16 + {{range $tag := split .annotation.Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} 12 17 </div> 13 18 {{end}} 14 - {{if .Rating.Valid}} 15 - <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (int .Rating.Int64)}}</div> 19 + {{if .annotation.Rating.Valid}} 20 + <div class="text-sm text-spot-orange mt-2 tracking-wide">{{repeat "&#9733;" (int .annotation.Rating.Int64)}}</div> 16 21 {{end}} 17 - <div class="text-xs text-spot-muted mt-2"> 18 - <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-green transition">{{.AuthorHandle}}</a> 19 - {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} 22 + <div class="flex items-center justify-between mt-3 pt-3 border-t border-spot-divider"> 23 + <div class="flex items-center gap-2 text-xs text-spot-muted"> 24 + <a href="/profile/{{.annotation.AuthorDID}}" class="hover:text-spot-green transition">{{.annotation.AuthorHandle}}</a> 25 + {{if .annotation.CreatedAt.Valid}}<span>&middot;</span><span>{{.annotation.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} 26 + </div> 27 + {{if eq .annotation.AuthorDID .userDID}} 28 + <div class="flex items-center gap-2"> 29 + <button class="text-xs text-spot-secondary hover:text-spot-text transition" onclick="editAnnotation(this, {{.annotation.ID}}, '{{js .annotation.FeedURL}}', '{{js .annotation.ArticleURL}}', '{{js .annotation.Quote.String}}', '{{js .annotation.Note.String}}', '{{js .annotation.Tags.String}}')">Edit</button> 30 + <button hx-post="/library/{{.annotation.ID}}/delete" hx-target="#annotation-{{.annotation.ID}}" hx-swap="outerHTML swap:0.3s" hx-confirm="Delete this annotation?" 31 + class="text-xs text-spot-secondary hover:text-spot-red transition">Delete</button> 32 + </div> 33 + {{end}} 20 34 </div> 21 35 </div> 22 36 {{end}}
+1 -1
internal/tmpl/profile.html
··· 38 38 <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2> 39 39 <div class="space-y-3"> 40 40 {{range .Annotations}} 41 - {{template "annotation-card.html" .}} 41 + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} 42 42 {{else}} 43 43 <p class="text-sm text-spot-secondary">No public annotations.</p> 44 44 {{end}}
+1
main.go
··· 99 99 cancel() 100 100 101 101 fmt.Println("glean stopped") 102 + os.Exit(0) 102 103 } 103 104 104 105 func envOr(key, fallback string) string {