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 auto complete on login page

+171 -36
+5 -5
internal/atproto/auth.go
··· 146 146 Handle: ident.Handle.String(), 147 147 } 148 148 149 - h, dn, avatar, err := FetchProfile(ctx, did) 149 + actor, err := FetchProfile(ctx, did) 150 150 if err != nil { 151 151 return p 152 152 } 153 153 154 - if h != "" { 155 - p.Handle = h 154 + if actor.Handle != "" { 155 + p.Handle = actor.Handle 156 156 } 157 - p.DisplayName = dn 158 - p.AvatarURL = avatar 157 + p.DisplayName = actor.DisplayName 158 + p.AvatarURL = actor.Avatar 159 159 160 160 profileCache.Store(did, &profileEntry{profile: p, fetched: time.Now()}) 161 161 return p
+45 -12
internal/atproto/profile.go
··· 9 9 "time" 10 10 ) 11 11 12 + type Actor struct { 13 + DID string `json:"did"` 14 + Handle string `json:"handle"` 15 + DisplayName string `json:"displayName"` 16 + Avatar string `json:"avatar"` 17 + } 18 + 12 19 var profileClient = &http.Client{Timeout: 10 * time.Second} 13 20 14 21 // FetchProfile is an unauthenticated profile fetcher. It relies on bluesky api. 15 - func FetchProfile(ctx context.Context, identifier string) (handle, displayName, avatarURL string, err error) { 22 + func FetchProfile(ctx context.Context, identifier string) (*Actor, error) { 16 23 apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", url.QueryEscape(identifier)) 17 24 return fetchProfileFromURL(ctx, apiURL, identifier) 18 25 } 19 26 20 - func fetchProfileFromURL(ctx context.Context, apiURL, identifier string) (handle, displayName, avatarURL string, err error) { 27 + func fetchProfileFromURL(ctx context.Context, apiURL, identifier string) (*Actor, error) { 28 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 29 + if err != nil { 30 + return nil, err 31 + } 32 + 33 + resp, err := profileClient.Do(req) 34 + if err != nil { 35 + return nil, err 36 + } 37 + defer resp.Body.Close() 38 + 39 + if resp.StatusCode != http.StatusOK { 40 + return nil, fmt.Errorf("fetch profile %s: status %d", identifier, resp.StatusCode) 41 + } 42 + 43 + var actor Actor 44 + if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil { 45 + return nil, err 46 + } 47 + return &actor, nil 48 + } 49 + 50 + func SearchActorsTypeahead(ctx context.Context, query string, limit int) ([]Actor, error) { 51 + if limit <= 0 { 52 + limit = 5 53 + } 54 + apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=%s&limit=%d", url.QueryEscape(query), limit) 55 + 21 56 req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) 22 57 if err != nil { 23 - return "", "", "", err 58 + return nil, err 24 59 } 25 60 26 61 resp, err := profileClient.Do(req) 27 62 if err != nil { 28 - return "", "", "", err 63 + return nil, err 29 64 } 30 65 defer resp.Body.Close() 31 66 32 67 if resp.StatusCode != http.StatusOK { 33 - return "", "", "", fmt.Errorf("fetch profile %s: status %d", identifier, resp.StatusCode) 68 + return nil, fmt.Errorf("search actors typeahead: status %d", resp.StatusCode) 34 69 } 35 70 36 - var profile struct { 37 - Handle string `json:"handle"` 38 - DisplayName string `json:"displayName"` 39 - Avatar string `json:"avatar"` 71 + var result struct { 72 + Actors []Actor `json:"actors"` 40 73 } 41 - if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { 42 - return "", "", "", err 74 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 75 + return nil, err 43 76 } 44 - return profile.Handle, profile.DisplayName, profile.Avatar, nil 77 + return result.Actors, nil 45 78 }
+14 -14
internal/atproto/profile_test.go
··· 14 14 func TestFetchProfile_Success(t *testing.T) { 15 15 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 16 assert.Equal(t, r.URL.Query().Get("actor"), "did:plc:test123") 17 - json.NewEncoder(w).Encode(map[string]string{ 18 - "handle": "test.bsky.social", 19 - "displayName": "Test User", 20 - "avatar": "https://cdn.bsky.app/img/avatar/test.png", 17 + json.NewEncoder(w).Encode(Actor{ 18 + Handle: "test.bsky.social", 19 + DisplayName: "Test User", 20 + Avatar: "https://cdn.bsky.app/img/avatar/test.png", 21 21 }) 22 22 })) 23 23 defer srv.Close() ··· 27 27 defer func() { profileClient = origClient }() 28 28 29 29 apiURL := srv.URL + "/xrpc/app.bsky.actor.getProfile?actor=" + url.QueryEscape("did:plc:test123") 30 - handle, dn, avatar, err := fetchProfileFromURL(context.Background(), apiURL, "did:plc:test123") 30 + actor, err := fetchProfileFromURL(context.Background(), apiURL, "did:plc:test123") 31 31 assert.NilError(t, err) 32 - assert.Equal(t, handle, "test.bsky.social") 33 - assert.Equal(t, dn, "Test User") 34 - assert.Equal(t, avatar, "https://cdn.bsky.app/img/avatar/test.png") 32 + assert.Equal(t, actor.Handle, "test.bsky.social") 33 + assert.Equal(t, actor.DisplayName, "Test User") 34 + assert.Equal(t, actor.Avatar, "https://cdn.bsky.app/img/avatar/test.png") 35 35 } 36 36 37 37 func TestFetchProfile_EmptyProfile(t *testing.T) { 38 38 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 - json.NewEncoder(w).Encode(map[string]string{}) 39 + json.NewEncoder(w).Encode(Actor{}) 40 40 })) 41 41 defer srv.Close() 42 42 ··· 44 44 profileClient = srv.Client() 45 45 defer func() { profileClient = origClient }() 46 46 47 - handle, dn, avatar, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123") 47 + actor, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123") 48 48 assert.NilError(t, err) 49 - assert.Equal(t, handle, "") 50 - assert.Equal(t, dn, "") 51 - assert.Equal(t, avatar, "") 49 + assert.Equal(t, actor.Handle, "") 50 + assert.Equal(t, actor.DisplayName, "") 51 + assert.Equal(t, actor.Avatar, "") 52 52 } 53 53 54 54 func TestFetchProfile_Non200(t *testing.T) { ··· 61 61 profileClient = srv.Client() 62 62 defer func() { profileClient = origClient }() 63 63 64 - _, _, _, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123") 64 + _, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123") 65 65 assert.Assert(t, err != nil) 66 66 }
+20
internal/server/auth_handler.go
··· 17 17 s.render(w, r, "login.html", map[string]any{}) 18 18 } 19 19 20 + func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) { 21 + q := strings.TrimPrefix(r.URL.Query().Get("q"), "@") 22 + if q == "" { 23 + w.Header().Set("Content-Type", "application/json") 24 + w.Write([]byte(`{"actors":[]}`)) 25 + return 26 + } 27 + 28 + actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5) 29 + if err != nil { 30 + s.logger.Warn("actor typeahead failed", "error", err) 31 + w.Header().Set("Content-Type", "application/json") 32 + w.Write([]byte(`{"actors":[]}`)) 33 + return 34 + } 35 + 36 + w.Header().Set("Content-Type", "application/json") 37 + json.NewEncoder(w).Encode(map[string]any{"actors": actors}) 38 + } 39 + 20 40 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { 21 41 handle := strings.TrimPrefix(r.FormValue("handle"), "@") 22 42 if handle == "" {
+1
internal/server/server.go
··· 204 204 }) 205 205 206 206 s.router.Get("/auth/login", s.handleAuthLogin) 207 + s.router.Get("/auth/resolve", s.handleAuthResolve) 207 208 s.router.Post("/auth/start", s.handleAuthStart) 208 209 s.router.Get("/auth/callback", s.handleAuthCallback) 209 210 s.router.Post("/auth/logout", s.handleAuthLogout)
+1 -1
internal/tmpl/base.html
··· 35 35 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 36 36 <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"> 37 37 <meta name="theme-color" content="#00754A"> 38 - <meta name="apple-mobile-web-app-capable" content="yes"> 38 + <meta name="mobile-web-app-capable" content="yes"> 39 39 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 40 40 <link rel="manifest" href="/static/manifest.json"> 41 41 <link rel="preconnect" href="https://fonts.googleapis.com">
+85 -4
internal/tmpl/login.html
··· 9 9 10 10 <form action="/auth/start" method="POST" id="login-form"> 11 11 {{csrfInput .CSRFToken}} 12 - <input type="text" name="handle" placeholder="you.bsky.social" 13 - class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder" 14 - required> 12 + <div class="relative mb-5"> 13 + <input type="text" name="handle" placeholder="you.bsky.social" id="handle-input" 14 + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder" 15 + required autocomplete="off"> 16 + <div id="handle-suggestions" class="absolute left-0 right-0 top-full mt-1 bg-spot-surface border border-spot-divider rounded-xl shadow-spot-heavy z-50 overflow-hidden hidden"></div> 17 + </div> 15 18 </form> 16 19 17 20 <div class="space-y-3"> ··· 30 33 31 34 <div class="mt-6 pt-6 border-t border-spot-divider text-center"> 32 35 <p class="text-xs text-spot-secondary mb-3">No account yet?</p> 33 - <a href="https://eurosky.social" target="_blank" rel="noopener noreferrer" 36 + <a href="https://portal.eurosky.tech/create-account" target="_blank" rel="noopener noreferrer" 34 37 class="inline-flex items-center justify-center gap-2 w-full border border-spot-outline text-spot-text rounded-pill px-4 py-2.5 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition"> 35 38 <span class="text-base">&#127466;&#127482;</span> 36 39 Create an account on Eurosky ··· 38 41 </div> 39 42 </div> 40 43 </div> 44 + <script> 45 + (function() { 46 + var input = document.getElementById('handle-input'); 47 + var box = document.getElementById('handle-suggestions'); 48 + var timer = null; 49 + var selected = -1; 50 + 51 + function close() { 52 + box.classList.add('hidden'); 53 + box.innerHTML = ''; 54 + selected = -1; 55 + } 56 + 57 + function highlight(items, idx) { 58 + items.forEach(function(el, i) { 59 + el.classList.toggle('bg-spot-hover', i === idx); 60 + }); 61 + } 62 + 63 + input.addEventListener('input', function() { 64 + clearTimeout(timer); 65 + var q = input.value.trim().replace(/^@/, ''); 66 + if (q.length < 1) { close(); return; } 67 + timer = setTimeout(function() { 68 + fetch('/auth/resolve?q=' + encodeURIComponent(q)) 69 + .then(function(r) { return r.json(); }) 70 + .then(function(data) { 71 + if (!data.actors || !data.actors.length) { close(); return; } 72 + selected = -1; 73 + box.innerHTML = ''; 74 + data.actors.forEach(function(a) { 75 + var row = document.createElement('button'); 76 + row.type = 'button'; 77 + row.className = 'w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-spot-hover transition'; 78 + var img = a.avatar 79 + ? '<img src="' + a.avatar + '" class="w-8 h-8 rounded-full shrink-0">' 80 + : '<div class="w-8 h-8 rounded-full bg-spot-hover shrink-0"></div>'; 81 + row.innerHTML = img + 82 + '<div class="min-w-0">' + 83 + '<div class="text-sm text-spot-text truncate">@' + a.handle + '</div>' + 84 + (a.displayName ? '<div class="text-xs text-spot-secondary truncate">' + a.displayName + '</div>' : '') + 85 + '</div>'; 86 + row.addEventListener('click', function() { 87 + input.value = a.handle; 88 + close(); 89 + }); 90 + box.appendChild(row); 91 + }); 92 + box.classList.remove('hidden'); 93 + }) 94 + .catch(function() { close(); }); 95 + }, 250); 96 + }); 97 + 98 + input.addEventListener('keydown', function(e) { 99 + var items = box.querySelectorAll('button'); 100 + if (!items.length) return; 101 + if (e.key === 'ArrowDown') { 102 + e.preventDefault(); 103 + selected = Math.min(selected + 1, items.length - 1); 104 + highlight(items, selected); 105 + } else if (e.key === 'ArrowUp') { 106 + e.preventDefault(); 107 + selected = Math.max(selected - 1, 0); 108 + highlight(items, selected); 109 + } else if (e.key === 'Enter' && selected >= 0) { 110 + e.preventDefault(); 111 + items[selected].click(); 112 + } else if (e.key === 'Escape') { 113 + close(); 114 + } 115 + }); 116 + 117 + document.addEventListener('click', function(e) { 118 + if (!box.contains(e.target) && e.target !== input) close(); 119 + }); 120 + })(); 121 + </script> 41 122 {{end}}