Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

Show ATProto handle and avatar in nav and presence

- Fix handle display in navbar: resolve handle from DID document
(alsoKnownAs field) instead of calling app.bsky.actor.getProfile
against the PDS, which doesn't serve AppView queries
- Fix avatar fetch: call public.api.bsky.app AppView instead of the
user's PDS for app.bsky.actor.getProfile
- Pass handle and avatar to collaboration presence: shown as tooltip
and avatar image on presence dots, falling back to colored circle
when no avatar is available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+201 -117
+41 -7
internal/atproto/identity.go
··· 50 50 51 51 // DIDDocument is the minimal structure we need from a DID document. 52 52 type DIDDocument struct { 53 - Service []struct { 53 + AlsoKnownAs []string `json:"alsoKnownAs"` 54 + Service []struct { 54 55 ID string `json:"id"` 55 56 Type string `json:"type"` 56 57 ServiceEndpoint string `json:"serviceEndpoint"` 57 58 } `json:"service"` 58 59 } 59 60 60 - // ResolvePDS resolves a DID to the PDS (Personal Data Server) URL. 61 - func ResolvePDS(did string) (string, error) { 61 + // HandleFromDIDDoc extracts the AT Protocol handle from a DID document's alsoKnownAs field. 62 + // Returns empty string if no handle is found. 63 + func HandleFromDIDDoc(doc *DIDDocument) string { 64 + for _, aka := range doc.AlsoKnownAs { 65 + if strings.HasPrefix(aka, "at://") { 66 + return strings.TrimPrefix(aka, "at://") 67 + } 68 + } 69 + return "" 70 + } 71 + 72 + // ResolveDIDDoc fetches and parses the DID document for a given DID. 73 + func ResolveDIDDoc(did string) (*DIDDocument, error) { 62 74 var docURL string 63 75 switch { 64 76 case strings.HasPrefix(did, "did:plc:"): ··· 67 79 host := strings.TrimPrefix(did, "did:web:") 68 80 docURL = "https://" + host + "/.well-known/did.json" 69 81 default: 70 - return "", fmt.Errorf("unsupported DID method: %s", did) 82 + return nil, fmt.Errorf("unsupported DID method: %s", did) 71 83 } 72 84 73 85 resp, err := httpClient.Get(docURL) 74 86 if err != nil { 75 - return "", fmt.Errorf("fetch DID doc for %s: %w", did, err) 87 + return nil, fmt.Errorf("fetch DID doc for %s: %w", did, err) 76 88 } 77 89 defer resp.Body.Close() 78 90 if resp.StatusCode != http.StatusOK { 79 - return "", fmt.Errorf("fetch DID doc for %s: HTTP %d", did, resp.StatusCode) 91 + return nil, fmt.Errorf("fetch DID doc for %s: HTTP %d", did, resp.StatusCode) 80 92 } 81 93 82 94 var doc DIDDocument 83 95 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 84 - return "", fmt.Errorf("decode DID doc: %w", err) 96 + return nil, fmt.Errorf("decode DID doc: %w", err) 97 + } 98 + return &doc, nil 99 + } 100 + 101 + // ResolvePDS resolves a DID to the PDS (Personal Data Server) URL. 102 + func ResolvePDS(did string) (string, error) { 103 + doc, err := ResolveDIDDoc(did) 104 + if err != nil { 105 + return "", err 85 106 } 86 107 87 108 for _, svc := range doc.Service { ··· 90 111 } 91 112 } 92 113 return "", fmt.Errorf("no #atproto_pds service in DID doc for %s", did) 114 + } 115 + 116 + // ResolveHandle resolves a DID to the AT Protocol handle via the DID document. 117 + func ResolveHandleFromDID(did string) (string, error) { 118 + doc, err := ResolveDIDDoc(did) 119 + if err != nil { 120 + return "", err 121 + } 122 + handle := HandleFromDIDDoc(doc) 123 + if handle == "" { 124 + return "", fmt.Errorf("no handle found in DID doc for %s", did) 125 + } 126 + return handle, nil 93 127 } 94 128 95 129 // AuthServerMeta holds the OAuth authorization server metadata fields we need.
+62
internal/atproto/profile.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + ) 8 + 9 + // Profile holds resolved ATProto profile information fetched at runtime 10 + type Profile struct { 11 + DID string `json:"did"` 12 + Handle string `json:"handle"` 13 + DisplayName string `json:"displayName"` 14 + Avatar string `json:"avatar"` 15 + PDSURL string `json:"pdsUrl"` 16 + } 17 + 18 + const bskyAppViewURL = "https://public.api.bsky.app" 19 + 20 + // ResolveProfile fetches a user's profile from the Bluesky public AppView. 21 + func ResolveProfile(did string) (*Profile, error) { 22 + url := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", bskyAppViewURL, did) 23 + 24 + resp, err := httpClient.Get(url) 25 + if err != nil { 26 + return nil, err 27 + } 28 + defer resp.Body.Close() 29 + 30 + if resp.StatusCode != http.StatusOK { 31 + return nil, fmt.Errorf("profile fetch failed: %d", resp.StatusCode) 32 + } 33 + 34 + var result struct { 35 + DID string `json:"did"` 36 + Handle string `json:"handle"` 37 + DisplayName string `json:"displayName"` 38 + Avatar string `json:"avatar"` 39 + } 40 + 41 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 42 + return nil, err 43 + } 44 + 45 + return &Profile{ 46 + DID: result.DID, 47 + Handle: result.Handle, 48 + DisplayName: result.DisplayName, 49 + Avatar: result.Avatar, 50 + }, nil 51 + } 52 + 53 + // GetDisplayName returns the best available name for a user 54 + func (p *Profile) GetDisplayName() string { 55 + if p.DisplayName != "" { 56 + return p.DisplayName 57 + } 58 + if p.Handle != "" && p.Handle != p.DID { 59 + return p.Handle 60 + } 61 + return p.DID 62 + }
+10 -4
internal/collaboration/client.go
··· 13 13 send chan []byte 14 14 DID string 15 15 Name string 16 + Handle string 16 17 Color string 18 + Avatar string 17 19 roomKey string 18 20 } 19 21 ··· 36 38 } 37 39 38 40 type PresenceUser struct { 39 - DID string `json:"did"` 40 - Name string `json:"name"` 41 - Color string `json:"color"` 41 + DID string `json:"did"` 42 + Name string `json:"name"` 43 + Handle string `json:"handle,omitempty"` 44 + Color string `json:"color"` 45 + Avatar string `json:"avatar,omitempty"` 42 46 } 43 47 44 48 type PresenceMessage struct { ··· 46 50 Users []PresenceUser `json:"users"` 47 51 } 48 52 49 - func NewClient(hub *Hub, conn *websocket.Conn, did, name, color, roomKey string) *Client { 53 + func NewClient(hub *Hub, conn *websocket.Conn, did, name, handle, color, avatar, roomKey string) *Client { 50 54 return &Client{ 51 55 hub: hub, 52 56 conn: conn, 53 57 send: make(chan []byte, 256), 54 58 DID: did, 55 59 Name: name, 60 + Handle: handle, 56 61 Color: color, 62 + Avatar: avatar, 57 63 roomKey: roomKey, 58 64 } 59 65 }
+5 -3
internal/collaboration/hub.go
··· 114 114 users := make([]PresenceUser, 0, len(r.clients)) 115 115 for client := range r.clients { 116 116 users = append(users, PresenceUser{ 117 - DID: client.DID, 118 - Name: client.Name, 119 - Color: client.Color, 117 + DID: client.DID, 118 + Name: client.Name, 119 + Handle: client.Handle, 120 + Color: client.Color, 121 + Avatar: client.Avatar, 120 122 }) 121 123 } 122 124 return users
+75 -100
internal/handler/handler.go
··· 14 14 "github.com/golang-jwt/jwt/v5" 15 15 "github.com/gorilla/websocket" 16 16 17 + "github.com/limeleaf/diffdown/internal/atproto" 17 18 "github.com/limeleaf/diffdown/internal/atproto/xrpc" 18 19 "github.com/limeleaf/diffdown/internal/auth" 19 20 "github.com/limeleaf/diffdown/internal/collaboration" ··· 41 42 type PageData struct { 42 43 Title string 43 44 User *model.User 45 + UserHandle string 44 46 Content interface{} 45 47 Error string 46 48 Description string ··· 62 64 OwnerDID string 63 65 } 64 66 65 - func (h *Handler) currentUser(r *http.Request) *model.User { 67 + func (h *Handler) currentUser(r *http.Request) (*model.User, string) { 66 68 uid := auth.UserIDFromContext(r.Context()) 67 69 if uid == "" { 68 - return nil 70 + return nil, "" 69 71 } 70 72 u, err := h.DB.GetUserByID(uid) 71 73 if err != nil { 72 - return nil 74 + return nil, "" 73 75 } 74 - return u 76 + session, err := h.DB.GetATProtoSession(uid) 77 + if err == nil && session != nil && session.DID != "" { 78 + handle, err := atproto.ResolveHandleFromDID(session.DID) 79 + if err == nil && handle != "" { 80 + return u, handle 81 + } 82 + return u, session.DID 83 + } 84 + return u, u.DID 75 85 } 76 86 77 87 func (h *Handler) render(w http.ResponseWriter, name string, data PageData) { ··· 100 110 101 111 // --- Auth handlers --- 102 112 103 - func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) { 104 - h.render(w, "login.html", PageData{Title: "Log In"}) 105 - } 106 - 107 - func (h *Handler) LoginSubmit(w http.ResponseWriter, r *http.Request) { 108 - email := strings.TrimSpace(r.FormValue("email")) 109 - password := r.FormValue("password") 110 - 111 - user, err := h.DB.GetUserByEmail(email) 112 - if err != nil || user.PasswordHash == nil || !auth.CheckPassword(*user.PasswordHash, password) { 113 - h.render(w, "login.html", PageData{Title: "Log In", Error: "Invalid email or password"}) 114 - return 115 - } 116 - 117 - auth.SetUserID(w, r, user.ID) 118 - http.Redirect(w, r, "/", http.StatusSeeOther) 119 - } 120 - 121 - func (h *Handler) RegisterPage(w http.ResponseWriter, r *http.Request) { 122 - h.render(w, "register.html", PageData{Title: "Sign Up"}) 123 - } 124 - 125 - func (h *Handler) RegisterSubmit(w http.ResponseWriter, r *http.Request) { 126 - name := strings.TrimSpace(r.FormValue("name")) 127 - email := strings.TrimSpace(r.FormValue("email")) 128 - password := r.FormValue("password") 129 - 130 - if name == "" || email == "" || len(password) < 8 { 131 - h.render(w, "register.html", PageData{Title: "Sign Up", Error: "Name, email required. Password min 8 chars."}) 132 - return 133 - } 134 - 135 - hash, err := auth.HashPassword(password) 136 - if err != nil { 137 - h.render(w, "register.html", PageData{Title: "Sign Up", Error: "Server error"}) 138 - return 139 - } 140 - 141 - user := &model.User{ 142 - Name: name, 143 - Email: email, 144 - PasswordHash: &hash, 145 - } 146 - if err := h.DB.CreateUser(user); err != nil { 147 - h.render(w, "register.html", PageData{Title: "Sign Up", Error: "Email already registered"}) 148 - return 149 - } 150 - 151 - auth.SetUserID(w, r, user.ID) 152 - http.Redirect(w, r, "/", http.StatusSeeOther) 153 - } 154 - 155 113 func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { 156 114 auth.ClearSession(w, r) 157 115 http.Redirect(w, r, "/", http.StatusSeeOther) ··· 160 118 // --- Page handlers --- 161 119 162 120 func (h *Handler) AboutPage(w http.ResponseWriter, r *http.Request) { 163 - user := h.currentUser(r) 121 + user, userHandle := h.currentUser(r) 164 122 h.render(w, "about.html", PageData{ 165 - Title: "About", 166 - User: user, 123 + Title: "About", 124 + User: user, 125 + UserHandle: userHandle, 167 126 }) 168 127 } 169 128 170 129 // --- Dashboard --- 171 130 172 131 func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 173 - user := h.currentUser(r) 132 + user, userHandle := h.currentUser(r) 174 133 if user == nil { 175 134 h.render(w, "landing.html", PageData{Title: "Diffdown"}) 176 135 return ··· 180 139 if err != nil { 181 140 log.Printf("Dashboard: xrpc client: %v", err) 182 141 h.render(w, "documents.html", PageData{ 183 - Title: "Documents", 184 - User: user, 185 - Content: []*model.Document{}, 142 + Title: "Documents", 143 + User: user, 144 + UserHandle: userHandle, 145 + Content: []*model.Document{}, 186 146 }) 187 147 return 188 148 } ··· 191 151 if err != nil { 192 152 log.Printf("Dashboard: list records: %v", err) 193 153 h.render(w, "documents.html", PageData{ 194 - Title: "Documents", 195 - User: user, 196 - Content: []*model.Document{}, 154 + Title: "Documents", 155 + User: user, 156 + UserHandle: userHandle, 157 + Content: []*model.Document{}, 197 158 }) 198 159 return 199 160 } ··· 211 172 } 212 173 213 174 h.render(w, "documents.html", PageData{ 214 - Title: "Documents", 215 - User: user, 216 - Content: docs, 175 + Title: "Documents", 176 + User: user, 177 + UserHandle: userHandle, 178 + Content: docs, 217 179 }) 218 180 } 219 181 220 182 // --- Document handlers --- 221 183 222 184 func (h *Handler) NewDocumentPage(w http.ResponseWriter, r *http.Request) { 223 - user := h.currentUser(r) 185 + user, userHandle := h.currentUser(r) 224 186 if user == nil { 225 187 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 226 188 return 227 189 } 228 - h.render(w, "new_document.html", PageData{Title: "New Document", User: user}) 190 + h.render(w, "new_document.html", PageData{ 191 + Title: "New Document", 192 + User: user, 193 + UserHandle: userHandle, 194 + }) 229 195 } 230 196 231 197 // stripMarkdown removes basic markdown syntax to produce plain text for textContent. ··· 236 202 } 237 203 238 204 func (h *Handler) NewDocumentSubmit(w http.ResponseWriter, r *http.Request) { 239 - user := h.currentUser(r) 205 + user, _ := h.currentUser(r) 240 206 if user == nil { 241 207 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 242 208 return ··· 286 252 // documentView is the shared implementation for viewing a document given an ownerDID and rkey. 287 253 // isOwner should be true when the current user owns the document; it suppresses the ownerDID 288 254 // in the template so the edit button links to /docs/{rkey}/edit rather than the collaborator URL. 289 - func (h *Handler) documentView(w http.ResponseWriter, r *http.Request, ownerUserID, ownerDID, rkey string, isOwner bool) { 255 + func (h *Handler) documentView(w http.ResponseWriter, r *http.Request, ownerUserID, ownerDID, rkey string, isOwner bool, userHandle string) { 290 256 client, err := h.xrpcClient(ownerUserID) 291 257 if err != nil { 292 258 http.Error(w, "Could not connect to PDS", 500) ··· 311 277 rendered, _ = render.Markdown([]byte(doc.Content.Text.RawMarkdown)) 312 278 } 313 279 314 - user := h.currentUser(r) 280 + user, _ := h.currentUser(r) 315 281 type DocumentViewData struct { 316 282 Doc *model.Document 317 283 Rendered template.HTML ··· 324 290 templateOwnerDID = "" 325 291 } 326 292 h.render(w, "document_view.html", PageData{ 327 - Title: doc.Title, 328 - User: user, 329 - Content: DocumentViewData{Doc: doc, Rendered: template.HTML(rendered), OwnerDID: templateOwnerDID}, 293 + Title: doc.Title, 294 + User: user, 295 + UserHandle: userHandle, 296 + Content: DocumentViewData{Doc: doc, Rendered: template.HTML(rendered), OwnerDID: templateOwnerDID}, 330 297 }) 331 298 } 332 299 333 300 // DocumentView renders a document as HTML (owner viewing their own doc). 334 301 func (h *Handler) DocumentView(w http.ResponseWriter, r *http.Request) { 335 - user := h.currentUser(r) 302 + user, userHandle := h.currentUser(r) 336 303 if user == nil { 337 304 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 338 305 return ··· 344 311 http.Error(w, "Could not connect to PDS", 500) 345 312 return 346 313 } 347 - h.documentView(w, r, user.ID, client.DID(), rkey, true) 314 + h.documentView(w, r, user.ID, client.DID(), rkey, true, userHandle) 348 315 } 349 316 350 317 // CollaboratorDocumentView renders a document owned by another user (collaborator access). 351 318 func (h *Handler) CollaboratorDocumentView(w http.ResponseWriter, r *http.Request) { 352 - user := h.currentUser(r) 319 + user, userHandle := h.currentUser(r) 353 320 if user == nil { 354 321 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 355 322 return ··· 364 331 return 365 332 } 366 333 367 - h.documentView(w, r, ownerUser.ID, ownerDID, rkey, false) 334 + h.documentView(w, r, ownerUser.ID, ownerDID, rkey, false, userHandle) 368 335 } 369 336 370 337 // documentEdit is the shared implementation for the edit page. 371 338 // ownerUserID/ownerDID identify whose PDS holds the document; isOwner is true for the creator. 372 - func (h *Handler) documentEdit(w http.ResponseWriter, r *http.Request, user *model.User, ownerUserID, ownerDID, rkey string, isOwner bool) { 339 + func (h *Handler) documentEdit(w http.ResponseWriter, r *http.Request, user *model.User, ownerUserID, ownerDID, rkey string, isOwner bool, userHandle string) { 373 340 ownerClient, err := h.xrpcClient(ownerUserID) 374 341 if err != nil { 375 342 http.Error(w, "Could not connect to PDS", 500) ··· 405 372 } 406 373 407 374 h.render(w, "document_edit.html", PageData{ 408 - Title: "Edit " + doc.Title, 409 - User: user, 410 - Content: editData, 375 + Title: "Edit " + doc.Title, 376 + User: user, 377 + UserHandle: userHandle, 378 + Content: editData, 411 379 }) 412 380 } 413 381 414 382 // DocumentEdit renders the editor for a document (owner). 415 383 func (h *Handler) DocumentEdit(w http.ResponseWriter, r *http.Request) { 416 - user := h.currentUser(r) 384 + user, userHandle := h.currentUser(r) 417 385 if user == nil { 418 386 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 419 387 return ··· 426 394 return 427 395 } 428 396 429 - h.documentEdit(w, r, user, user.ID, client.DID(), rkey, true) 397 + h.documentEdit(w, r, user, user.ID, client.DID(), rkey, true, userHandle) 430 398 } 431 399 432 400 // CollaboratorDocumentEdit renders the editor for a document owned by another user. 433 401 func (h *Handler) CollaboratorDocumentEdit(w http.ResponseWriter, r *http.Request) { 434 - user := h.currentUser(r) 402 + user, userHandle := h.currentUser(r) 435 403 if user == nil { 436 404 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 437 405 return ··· 446 414 return 447 415 } 448 416 449 - h.documentEdit(w, r, user, ownerUser.ID, ownerDID, rkey, false) 417 + h.documentEdit(w, r, user, ownerUser.ID, ownerDID, rkey, false, userHandle) 450 418 } 451 419 452 420 // APIDocumentSave saves a document to the PDS. 453 421 func (h *Handler) APIDocumentSave(w http.ResponseWriter, r *http.Request) { 454 - user := h.currentUser(r) 422 + user, _ := h.currentUser(r) 455 423 if user == nil { 456 424 http.Error(w, "Unauthorized", 401) 457 425 return ··· 541 509 542 510 // APIDocumentDelete deletes a document from the PDS. 543 511 func (h *Handler) APIDocumentDelete(w http.ResponseWriter, r *http.Request) { 544 - user := h.currentUser(r) 512 + user, _ := h.currentUser(r) 545 513 if user == nil { 546 514 http.Error(w, "Unauthorized", 401) 547 515 return ··· 565 533 566 534 // DocumentInvite creates an invite link for a document. 567 535 func (h *Handler) DocumentInvite(w http.ResponseWriter, r *http.Request) { 568 - user := h.currentUser(r) 536 + user, _ := h.currentUser(r) 569 537 if user == nil { 570 - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 538 + http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 571 539 return 572 540 } 573 541 ··· 616 584 617 585 // AcceptInvite handles an invite acceptance. 618 586 func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { 619 - user := h.currentUser(r) 587 + user, _ := h.currentUser(r) 620 588 if user == nil { 621 589 // Preserve invite token through the login redirect. 622 - http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) 590 + http.Redirect(w, r, "/auth/atproto?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) 623 591 return 624 592 } 625 593 ··· 692 660 // --- API: Comments --- 693 661 694 662 func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 695 - user := h.currentUser(r) 663 + user, _ := h.currentUser(r) 696 664 if user == nil { 697 665 http.Error(w, "Unauthorized", http.StatusUnauthorized) 698 666 return ··· 754 722 return 755 723 } 756 724 757 - user := h.currentUser(r) 725 + user, _ := h.currentUser(r) 758 726 if user == nil { 759 727 http.Error(w, "Unauthorized", http.StatusUnauthorized) 760 728 return ··· 891 859 892 860 color := colorFromDID(did) 893 861 862 + // Fetch handle and avatar from ATProto profile; best-effort, empty on failure. 863 + var avatar, handle string 864 + if profile, err := atproto.ResolveProfile(did); err == nil && profile != nil { 865 + avatar = profile.Avatar 866 + handle = profile.Handle 867 + } 868 + 894 869 conn, err := upgrader.Upgrade(w, r, nil) 895 870 if err != nil { 896 871 log.Printf("WebSocket upgrade failed: %v", err) ··· 898 873 } 899 874 900 875 room := h.CollaborationHub.GetOrCreateRoom(rKey) 901 - wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 876 + wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, handle, color, avatar, rKey) 902 877 room.RegisterClient(wsClient) 903 878 904 879 go wsClient.WritePump() ··· 953 928 // Response 200: {"version": N} 954 929 // Response 409: {"version": N, "steps": ["...json..."]} — client must rebase 955 930 func (h *Handler) SubmitSteps(w http.ResponseWriter, r *http.Request) { 956 - user := h.currentUser(r) 931 + user, _ := h.currentUser(r) 957 932 if user == nil { 958 933 http.Error(w, "Unauthorized", http.StatusUnauthorized) 959 934 return ··· 1022 997 // GET /api/docs/{rkey}/steps?since={v} 1023 998 // Response 200: {"version": N, "steps": ["...json..."]} 1024 999 func (h *Handler) GetSteps(w http.ResponseWriter, r *http.Request) { 1025 - user := h.currentUser(r) 1000 + user, _ := h.currentUser(r) 1026 1001 if user == nil { 1027 1002 http.Error(w, "Unauthorized", http.StatusUnauthorized) 1028 1003 return
+1
static/css/editor.css
··· 217 217 box-shadow: 0 0 0 1px var(--border); 218 218 flex-shrink: 0; 219 219 transition: transform 0.15s; 220 + object-fit: cover; 220 221 } 221 222 222 223 .presence-avatar:hover {
+7 -3
templates/document_edit.html
··· 745 745 function updatePresence(users) { 746 746 const list = document.getElementById('presence-list'); 747 747 if (!list) return; 748 - list.innerHTML = users.map(u => ` 749 - <span class="presence-avatar" style="background:${u.color}" title="${escHtml(u.name || u.did)}"></span> 750 - `).join(''); 748 + list.innerHTML = users.map(u => { 749 + const label = escHtml(u.handle || u.name || u.did); 750 + if (u.avatar) { 751 + return `<img class="presence-avatar" src="${escHtml(u.avatar)}" title="${label}" alt="${label}">`; 752 + } 753 + return `<span class="presence-avatar" style="background:${u.color}" title="${label}"></span>`; 754 + }).join(''); 751 755 } 752 756 753 757 function escHtml(str) {