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.

Refactor atproto client to use APIClient directly and fix UI

+25 -166
+1 -1
.gitignore
··· 23 23 24 24 # database 25 25 *.db 26 - *.db.journal 26 + *.db-journal 27 27 28 28 # tailwind 29 29 static/output.css
+8 -145
internal/atproto/client.go
··· 1 1 package atproto 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "encoding/json" 7 6 "fmt" 8 - "net/http" 9 7 10 8 "github.com/bluesky-social/indigo/atproto/atclient" 11 9 "github.com/bluesky-social/indigo/atproto/syntax" 12 10 ) 13 11 14 12 type Client struct { 15 - httpClient *http.Client 16 - pdsURL string 17 - accessToken string 18 - APIClient *atclient.APIClient 13 + api *atclient.APIClient 19 14 } 20 15 21 - func NewClient(pdsURL, accessToken string) *Client { 22 - return &Client{ 23 - httpClient: &http.Client{}, 24 - pdsURL: pdsURL, 25 - accessToken: accessToken, 26 - } 16 + func NewClient(api *atclient.APIClient) *Client { 17 + return &Client{api: api} 27 18 } 28 19 29 20 func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { 30 - if c.APIClient != nil { 31 - return c.createRecordWithAPI(ctx, did, collection, record) 32 - } 33 - 34 - nsid, err := syntax.ParseNSID(collection) 35 - if err != nil { 36 - return "", "", fmt.Errorf("parsing collection NSID: %w", err) 37 - } 38 - 39 - body := map[string]any{ 40 - "repo": did, 41 - "collection": nsid.String(), 42 - "record": record, 43 - } 44 - 45 - data, err := json.Marshal(body) 46 - if err != nil { 47 - return "", "", err 48 - } 49 - 50 - url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord" 51 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) 52 - if err != nil { 53 - return "", "", err 54 - } 55 - req.Header.Set("Content-Type", "application/json") 56 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 57 - 58 - resp, err := c.httpClient.Do(req) 59 - if err != nil { 60 - return "", "", err 61 - } 62 - defer resp.Body.Close() 63 - 64 - if resp.StatusCode != http.StatusOK { 65 - return "", "", fmt.Errorf("create record returned %d", resp.StatusCode) 66 - } 67 - 68 - var result struct { 69 - URI string `json:"uri"` 70 - CID string `json:"cid"` 71 - } 72 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 73 - return "", "", err 74 - } 75 - 76 - return result.URI, result.CID, nil 77 - } 78 - 79 - func (c *Client) createRecordWithAPI(ctx context.Context, did, collection string, record any) (string, string, error) { 80 21 input := map[string]any{ 81 22 "repo": did, 82 23 "collection": collection, ··· 93 34 return "", "", fmt.Errorf("parsing NSID: %w", err) 94 35 } 95 36 96 - if err := c.APIClient.Post(ctx, nsid, input, &out); err != nil { 37 + if err := c.api.Post(ctx, nsid, input, &out); err != nil { 97 38 return "", "", err 98 39 } 99 40 return out.URI, out.CID, nil ··· 106 47 "rkey": rkey, 107 48 } 108 49 109 - if c.APIClient != nil { 110 - nsid, err := syntax.ParseNSID("com.atproto.repo.deleteRecord") 111 - if err != nil { 112 - return fmt.Errorf("parsing NSID: %w", err) 113 - } 114 - return c.APIClient.Post(ctx, nsid, input, nil) 115 - } 116 - 117 - data, err := json.Marshal(input) 50 + nsid, err := syntax.ParseNSID("com.atproto.repo.deleteRecord") 118 51 if err != nil { 119 - return err 120 - } 121 - 122 - url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord" 123 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) 124 - if err != nil { 125 - return err 126 - } 127 - req.Header.Set("Content-Type", "application/json") 128 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 129 - 130 - resp, err := c.httpClient.Do(req) 131 - if err != nil { 132 - return err 133 - } 134 - defer resp.Body.Close() 135 - 136 - if resp.StatusCode != http.StatusOK { 137 - return fmt.Errorf("delete record returned %d", resp.StatusCode) 52 + return fmt.Errorf("parsing NSID: %w", err) 138 53 } 139 54 140 - return nil 55 + return c.api.Post(ctx, nsid, input, nil) 141 56 } 142 57 143 58 func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) { 144 - if c.APIClient != nil { 145 - return c.listRecordsWithAPI(ctx, did, collection, limit, cursor) 146 - } 147 - 148 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection) 149 - if limit > 0 { 150 - url += fmt.Sprintf("&limit=%d", limit) 151 - } 152 - if cursor != "" { 153 - url += "&cursor=" + cursor 154 - } 155 - 156 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 157 - if err != nil { 158 - return nil, "", err 159 - } 160 - req.Header.Set("Authorization", "Bearer "+c.accessToken) 161 - 162 - resp, err := c.httpClient.Do(req) 163 - if err != nil { 164 - return nil, "", err 165 - } 166 - defer resp.Body.Close() 167 - 168 - if resp.StatusCode != http.StatusOK { 169 - return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode) 170 - } 171 - 172 - var result struct { 173 - Records []struct { 174 - URI string `json:"uri"` 175 - CID string `json:"cid"` 176 - Value json.RawMessage `json:"value"` 177 - } `json:"records"` 178 - Cursor string `json:"cursor"` 179 - } 180 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 181 - return nil, "", err 182 - } 183 - 184 - records := make([]Record, len(result.Records)) 185 - for i, r := range result.Records { 186 - records[i] = Record{ 187 - URI: r.URI, 188 - CID: r.CID, 189 - Value: r.Value, 190 - } 191 - } 192 - return records, result.Cursor, nil 193 - } 194 - 195 - func (c *Client) listRecordsWithAPI(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) { 196 59 nsid, err := syntax.ParseNSID("com.atproto.repo.listRecords") 197 60 if err != nil { 198 61 return nil, "", fmt.Errorf("parsing NSID: %w", err) ··· 218 81 Cursor string `json:"cursor"` 219 82 } 220 83 221 - if err := c.APIClient.Get(ctx, nsid, params, &result); err != nil { 84 + if err := c.api.Get(ctx, nsid, params, &result); err != nil { 222 85 return nil, "", err 223 86 } 224 87
+1 -1
internal/server/auth_handler.go
··· 157 157 s.logger.Warn("failed to resume session for sync", "error", err) 158 158 return nil 159 159 } 160 - return &atproto.Client{APIClient: session.APIClient()} 160 + return atproto.NewClient(session.APIClient()) 161 161 } 162 162 163 163 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
+12 -16
internal/server/server.go
··· 229 229 return nil 230 230 } 231 231 232 - if session.SessionID != "" { 233 - did, err := syntax.ParseDID(session.DID) 234 - if err != nil { 235 - return nil 236 - } 237 - sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID) 238 - if err != nil { 239 - s.logger.Warn("failed to resume OAuth session", "error", err) 240 - return nil 241 - } 242 - apiClient := sess.APIClient() 243 - return &atproto.Client{APIClient: apiClient} 232 + if session.SessionID == "" { 233 + return nil 244 234 } 245 235 246 - if session.AccessToken != "" && session.PDSURL != "" { 247 - return atproto.NewClient(session.PDSURL, session.AccessToken) 236 + did, err := syntax.ParseDID(session.DID) 237 + if err != nil { 238 + return nil 239 + } 240 + sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID) 241 + if err != nil { 242 + s.logger.Warn("failed to resume OAuth session", "error", err) 243 + return nil 248 244 } 249 - return nil 245 + return atproto.NewClient(sess.APIClient()) 250 246 } 251 247 252 248 func (s *Server) syncUserInBackground(userDID string, client *atproto.Client) { ··· 298 294 continue 299 295 } 300 296 301 - client := &atproto.Client{APIClient: sess.APIClient()} 297 + client := atproto.NewClient(sess.APIClient()) 302 298 sync := atproto.NewSync(s.db, client, s.logger) 303 299 if err := sync.Run(ctx, u.DID); err != nil { 304 300 s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
+1 -1
internal/tmpl/feeds.html
··· 123 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 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 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> 126 + {{if .Subscriptions}}<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>{{end}} 127 127 </div> 128 128 </div> 129 129
+2 -2
internal/tmpl/partials/logo.html
··· 15 15 16 16 {{define "logo-link"}}<a href="/" class="text-spot-green font-bold text-xl tracking-tight flex items-center gap-2"> 17 17 <span class="w-7 h-7">{{template "logo-icon"}}</span> 18 - Glean.at 18 + <span class="font-bold text-xl text-spot-text">Glean</span> 19 19 </a>{{end}} 20 20 21 - {{define "logo-text"}}<a href="/" class="text-spot-green font-bold text-lg tracking-tight">Glean.at</a>{{end}} 21 + {{define "logo-text"}}<a href="/" class="text-spot-green font-bold text-lg tracking-tight"><span class="font-bold text-xl text-spot-text">Glean</span>.at</a>{{end}}