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.

Add subscription title storage and clear all feature

+182 -83
+1 -1
internal/atproto/firehose_handler.go
··· 52 52 53 53 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)} 54 54 _ = h.db.UpsertFeed(ctx, f) 55 - return h.db.CreateSubscription(ctx, event.DID, rec.FeedURL, rec.Category, event.URI, event.CID) 55 + return h.db.CreateSubscription(ctx, event.DID, rec.FeedURL, rec.Title, rec.Category, event.URI, event.CID) 56 56 57 57 case "delete": 58 58 parsed, ok := ParseRecordURI(event.URI)
+1 -1
internal/atproto/sync.go
··· 80 80 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)} 81 81 _ = s.db.UpsertFeed(ctx, f) 82 82 83 - return s.db.CreateSubscription(ctx, userDID, rec.FeedURL, rec.Category, uri, cid) 83 + return s.db.CreateSubscription(ctx, userDID, rec.FeedURL, rec.Title, rec.Category, uri, cid) 84 84 } 85 85 86 86 func (s *Sync) reconcileLike(ctx context.Context, userDID, uri, cid string, value json.RawMessage) error {
+2 -2
internal/atproto/xrpc.go
··· 25 25 cursor := r.URL.Query().Get("cursor") 26 26 27 27 query := ` 28 - SELECT s.id, f.feed_url, f.title, s.category, s.added_at 28 + SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at 29 29 FROM subscriptions s 30 30 JOIN feeds f ON s.feed_url = f.feed_url 31 31 WHERE s.user_did = ?` ··· 428 428 } 429 429 430 430 subRows, err := h.db.QueryContext(r.Context(), ` 431 - SELECT f.feed_url, f.title, s.category 431 + SELECT s.feed_url, COALESCE(s.title, f.title), s.category 432 432 FROM subscriptions s 433 433 JOIN feeds f ON s.feed_url = f.feed_url 434 434 WHERE s.user_did = ?
+1
internal/db/db.go
··· 85 85 id INTEGER PRIMARY KEY AUTOINCREMENT, 86 86 user_did TEXT NOT NULL REFERENCES users(did), 87 87 feed_url TEXT NOT NULL REFERENCES feeds(feed_url), 88 + title TEXT, 88 89 category TEXT, 89 90 added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 90 91 uri TEXT,
+49 -6
internal/db/feed.go
··· 126 126 return err 127 127 } 128 128 129 - func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category, uri, cid string) error { 129 + func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { 130 130 _, err := db.ExecContext(ctx, ` 131 - INSERT INTO subscriptions (user_did, feed_url, category, uri, cid) 132 - VALUES (?, ?, ?, ?, ?) 133 - `, userDID, feedURL, category, uriOrNil(category, uri), uriOrNil(category, cid)) 131 + INSERT INTO subscriptions (user_did, feed_url, title, category, uri, cid) 132 + VALUES (?, ?, ?, ?, ?, ?) 133 + `, userDID, feedURL, nilIfEmpty(title), category, uriOrNil(category, uri), uriOrNil(category, cid)) 134 134 if err != nil { 135 135 return err 136 136 } ··· 151 151 return v 152 152 } 153 153 154 + func nilIfEmpty(v string) any { 155 + if v == "" { 156 + return nil 157 + } 158 + return v 159 + } 160 + 154 161 func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error { 155 162 _, err := db.ExecContext(ctx, ` 156 163 DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ? ··· 161 168 return db.DecrementSubscriberCount(ctx, feedURL) 162 169 } 163 170 171 + func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error { 172 + tx, err := db.BeginTx(ctx, nil) 173 + if err != nil { 174 + return err 175 + } 176 + defer tx.Rollback() 177 + 178 + rows, err := tx.QueryContext(ctx, `SELECT feed_url FROM subscriptions WHERE user_did = ?`, userDID) 179 + if err != nil { 180 + return err 181 + } 182 + var feedURLs []string 183 + for rows.Next() { 184 + var u string 185 + if err := rows.Scan(&u); err != nil { 186 + rows.Close() 187 + return err 188 + } 189 + feedURLs = append(feedURLs, u) 190 + } 191 + rows.Close() 192 + 193 + _, err = tx.ExecContext(ctx, `DELETE FROM subscriptions WHERE user_did = ?`, userDID) 194 + if err != nil { 195 + return err 196 + } 197 + 198 + for _, u := range feedURLs { 199 + if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil { 200 + return err 201 + } 202 + } 203 + 204 + return tx.Commit() 205 + } 206 + 164 207 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { 165 208 s := &Subscription{} 166 209 err := db.QueryRowContext(ctx, ` 167 - SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at, 210 + SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, 168 211 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid 169 212 FROM subscriptions s 170 213 LEFT JOIN feeds f ON s.feed_url = f.feed_url ··· 177 220 } 178 221 179 222 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { 180 - query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at, 223 + query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, 181 224 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid 182 225 FROM subscriptions s 183 226 LEFT JOIN feeds f ON s.feed_url = f.feed_url
+61 -29
internal/feed/opml.go
··· 4 4 "bytes" 5 5 "encoding/xml" 6 6 "io" 7 + "strings" 7 8 ) 8 9 9 10 type OPML struct { ··· 18 19 } 19 20 20 21 type Outline struct { 21 - Text string `xml:"text,attr"` 22 - Title string `xml:"title,attr"` 23 - XMLURL string `xml:"xmlUrl,attr"` 24 - HTMLURL string `xml:"htmlUrl,attr"` 25 - Outlines []Outline `xml:"outline"` 22 + Text string `xml:"text,attr"` 23 + Title string `xml:"title,attr"` 24 + XMLURL string `xml:"xmlUrl,attr"` 25 + HTMLURL string `xml:"htmlUrl,attr"` 26 + Description string `xml:"description,attr"` 27 + Outlines []Outline `xml:"outline"` 26 28 } 27 29 28 - type FeedURL struct { 29 - URL string 30 - Title string 31 - Category string 30 + func (o Outline) GetTitle() string { 31 + if o.Title != "" { 32 + return o.Title 33 + } 34 + if o.Text != "" { 35 + return o.Text 36 + } 37 + if o.HTMLURL != "" { 38 + return o.HTMLURL 39 + } 40 + if o.XMLURL != "" { 41 + return o.XMLURL 42 + } 43 + return "" 32 44 } 33 45 34 - func ParseOPML(r io.Reader) (*OPML, error) { 35 - data, err := io.ReadAll(r) 36 - if err != nil { 37 - return nil, err 46 + func (o Outline) GetSiteURL() string { 47 + if o.HTMLURL != "" { 48 + return o.HTMLURL 38 49 } 50 + return o.XMLURL 51 + } 39 52 53 + func (o Outline) IsSubscription() bool { 54 + return strings.TrimSpace(o.XMLURL) != "" 55 + } 56 + 57 + func (o Outline) HasChildren() bool { 58 + return len(o.Outlines) > 0 59 + } 60 + 61 + type FeedURL struct { 62 + URL string 63 + Title string 64 + SiteURL string 65 + Description string 66 + Category string 67 + } 68 + 69 + func ParseOPML(r io.Reader) (*OPML, error) { 40 70 var opml OPML 41 - if err := xml.Unmarshal(data, &opml); err != nil { 71 + dec := xml.NewDecoder(r) 72 + dec.Strict = false 73 + if err := dec.Decode(&opml); err != nil { 42 74 return nil, err 43 75 } 44 - 45 76 return &opml, nil 46 77 } 47 78 ··· 53 84 54 85 func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) { 55 86 for _, o := range outlines { 56 - if o.XMLURL != "" { 87 + if o.IsSubscription() { 57 88 *urls = append(*urls, FeedURL{ 58 - URL: o.XMLURL, 59 - Title: o.Title, 60 - Category: category, 89 + URL: o.XMLURL, 90 + Title: o.GetTitle(), 91 + SiteURL: o.GetSiteURL(), 92 + Description: o.Description, 93 + Category: category, 61 94 }) 95 + } else if o.HasChildren() { 96 + extractOutlines(o.Outlines, o.GetTitle(), urls) 62 97 } 63 - childCategory := o.Text 64 - if childCategory == "" { 65 - childCategory = category 66 - } 67 - extractOutlines(o.Outlines, childCategory, urls) 68 98 } 69 99 } 70 100 ··· 75 105 opml.Head.Title = title 76 106 77 107 for _, f := range feeds { 78 - opml.Body.Outlines = append(opml.Body.Outlines, Outline{ 79 - Text: f.Title, 80 - Title: f.Title, 81 - XMLURL: f.URL, 82 - }) 108 + outline := Outline{ 109 + Text: f.Title, 110 + Title: f.Title, 111 + XMLURL: f.URL, 112 + HTMLURL: f.SiteURL, 113 + } 114 + opml.Body.Outlines = append(opml.Body.Outlines, outline) 83 115 } 84 116 85 117 var buf bytes.Buffer
+38 -17
internal/server/feeds_handler.go
··· 42 42 s.render(w, r, "feeds.html", map[string]any{ 43 43 "User": user, 44 44 "Subscriptions": subs, 45 + "SubscriptionCount": len(allSubs), 45 46 "Categories": categories, 46 47 "Category": category, 47 48 "FeedRecommendations": feedRecs, ··· 116 117 subCID = cid 117 118 } 118 119 119 - if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category, subURI, subCID); err != nil { 120 + if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil { 120 121 s.logger.Error("failed to create subscription", "error", err) 121 122 http.Error(w, err.Error(), http.StatusInternalServerError) 122 123 return ··· 158 159 return 159 160 } 160 161 161 - w.WriteHeader(http.StatusNoContent) 162 + w.WriteHeader(http.StatusOK) 163 + } 164 + 165 + func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Request) { 166 + user := currentUser(r) 167 + 168 + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0) 169 + if client := s.pdsClientForUser(r); client != nil { 170 + for _, sub := range subs { 171 + if sub.URI.Valid { 172 + parsed, ok := atproto.ParseRecordURI(sub.URI.String) 173 + if ok { 174 + if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil { 175 + s.logger.Error("failed to delete subscription from PDS", "error", delErr, "uri", sub.URI.String) 176 + } 177 + } 178 + } 179 + } 180 + } 181 + 182 + if err := s.db.DeleteAllSubscriptions(r.Context(), user.DID); err != nil { 183 + s.logger.Error("failed to clear subscriptions", "error", err) 184 + http.Error(w, err.Error(), http.StatusInternalServerError) 185 + return 186 + } 187 + 188 + w.Header().Set("HX-Redirect", "/feeds") 189 + w.WriteHeader(http.StatusOK) 162 190 } 163 191 164 192 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { ··· 181 209 client := s.pdsClientForUser(r) 182 210 for _, fu := range feedURLs { 183 211 f := &db.Feed{ 184 - FeedURL: fu.URL, 185 - Title: nullString(fu.Title), 212 + FeedURL: fu.URL, 213 + Title: nullString(fu.Title), 214 + SiteURL: nullString(fu.SiteURL), 215 + Description: nullString(fu.Description), 186 216 } 187 217 if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil { 188 218 s.logger.Error("failed to upsert feed", "error", upsertErr) ··· 206 236 subCID = cid 207 237 } 208 238 209 - if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category, subURI, subCID); subErr != nil { 239 + if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Title, fu.Category, subURI, subCID); subErr != nil { 210 240 s.logger.Error("failed to create subscription", "error", subErr) 211 241 continue 212 242 } 213 243 added++ 214 244 } 215 245 216 - subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) 217 - s.render(w, r, "feeds.html", map[string]any{ 218 - "User": user, 219 - "Subscriptions": subs, 220 - "AddedCount": added, 221 - }) 246 + w.Header().Set("HX-Redirect", "/feeds") 247 + w.WriteHeader(http.StatusOK) 222 248 } 223 249 224 250 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) { ··· 227 253 228 254 var feedURLs []feed.FeedURL 229 255 for _, sub := range subs { 230 - f, err := s.db.GetFeed(r.Context(), sub.FeedURL) 231 - title := "" 232 - if err == nil { 233 - title = f.Title.String 234 - } 235 256 feedURLs = append(feedURLs, feed.FeedURL{ 236 257 URL: sub.FeedURL, 237 - Title: title, 258 + Title: sub.FeedTitle, 238 259 }) 239 260 } 240 261
+1
internal/server/server.go
··· 107 107 r.Get("/list", s.handleFeedList) 108 108 r.Post("/set-interval", s.handleUpdateFeedInterval) 109 109 r.Get("/discover-url", s.handleDiscoverFeedURL) 110 + r.Post("/clear", s.handleClearAllSubscriptions) 110 111 }) 111 112 112 113 s.router.Route("/articles", func(r chi.Router) {
-11
internal/tmpl/dashboard.html
··· 103 103 </div> 104 104 </div> 105 105 {{end}} 106 - 107 - <div> 108 - <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2> 109 - <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2"> 110 - {{csrfInput .CSRFToken}} 111 - <input type="url" name="feed_url" placeholder="Feed URL" 112 - class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder" 113 - required> 114 - <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button> 115 - </form> 116 - </div> 117 106 </div> 118 107 </div> 119 108 {{end}}
+28 -16
internal/tmpl/feeds.html
··· 1 1 {{define "feeds.html"}} 2 2 <div class="flex items-center justify-between mb-2"> 3 - <h1 class="text-2xl font-bold text-spot-text">Feeds</h1> 3 + <h1 class="text-2xl font-bold text-spot-text">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1> 4 4 <div class="flex items-center gap-3"> 5 5 <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" 6 6 hx-indicator="#refresh-indicator" ··· 53 53 54 54 <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider"> 55 55 {{range .Subscriptions}} 56 - <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> 57 - <div class="min-w-0"> 58 - <div class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div> 59 - {{if and .FeedTitle .FeedURL}}<div class="text-xs text-spot-muted truncate">{{.FeedURL}}</div>{{end}} 60 - {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} 56 + <div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> 57 + <div class="min-w-0 flex-1"> 58 + <div class="flex items-center gap-2"> 59 + <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> 60 + {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} 61 + </div> 62 + <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> 61 63 </div> 62 64 <div class="flex items-center gap-3"> 63 65 {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} 64 - <form hx-post="/feeds/set-interval" hx-swap="none" hx-vals='{"feed_url": "{{.FeedURL}}"}' class="inline-flex items-center gap-1"> 66 + <form hx-post="/feeds/set-interval" hx-swap="none" class="inline-flex items-center gap-1"> 65 67 {{csrfInput $.CSRFToken}} 66 68 <input type="hidden" name="feed_url" value="{{.FeedURL}}"> 67 69 <select name="interval" onchange="this.form.submit()" ··· 75 77 <option value="1440" {{if eq .FetchInterval 1440}}selected{{end}}>24h</option> 76 78 </select> 77 79 </form> 78 - <form hx-delete="/feeds/remove" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s" 80 + <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" 79 81 hx-confirm="Unsubscribe from this feed?" class="inline"> 80 82 {{csrfInput $.CSRFToken}} 81 83 <input type="hidden" name="url" value="{{.FeedURL}}"> ··· 85 87 </div> 86 88 {{else}} 87 89 <div class="px-4 py-8 text-center text-spot-secondary"> 88 - <p>No feeds yet. Add one below!</p> 90 + <p>No feeds yet. Add one from the sidebar.</p> 89 91 </div> 90 92 {{end}} 91 93 </div> ··· 94 96 <div class="text-center py-4"> 95 97 <button hx-get="/feeds?offset={{.Page.NextOffset}}{{if .Category}}&category={{.Category}}{{end}}" 96 98 hx-target="#feed-list" hx-swap="beforeend" 97 - hx-select=".px-5" 99 + hx-select=".feed-item" 98 100 class="text-sm text-spot-secondary hover:text-spot-green transition"> 99 101 Load more 100 102 </button> ··· 114 116 class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"> 115 117 <button type="submit" class="w-full bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button> 116 118 </form> 117 - <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2"> 118 - {{csrfInput .CSRFToken}} 119 - <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label> 120 - <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer"> 121 - <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button> 122 - </form> 119 + <div class="border-t border-spot-divider pt-4 space-y-3"> 120 + <h2 class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import / Export</h2> 121 + <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2"> 122 + {{csrfInput .CSRFToken}} 123 + <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer"> 124 + <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button> 125 + </form> 126 + <a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a> 127 + </div> 123 128 </div> 124 129 125 130 {{if .PeopleRecommendations}} ··· 138 143 {{end}} 139 144 </div> 140 145 </div> 146 + {{end}} 147 + 148 + {{if .Subscriptions}} 149 + <form hx-post="/feeds/clear" hx-confirm="Are you sure you want to unsubscribe from ALL feeds? This cannot be undone."> 150 + {{csrfInput .CSRFToken}} 151 + <button type="submit" class="w-full border border-spot-red/50 text-spot-red rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-red hover:bg-spot-red/10 transition">Clear all subscriptions</button> 152 + </form> 141 153 {{end}} 142 154 </div> 143 155 </div>