diff --git a/public/editor/collab.ts b/public/editor/collab.ts index b55f9fa..c9feaae 100644 --- a/public/editor/collab.ts +++ b/public/editor/collab.ts @@ -41,6 +41,8 @@ interface CollabUser { did: string; handle: string; displayName: string; + /** Profile picture URL, broadcast to peers via awareness. Empty if none. */ + avatar?: string; } /** One connected editor, surfaced for the presence avatar cluster. */ @@ -50,6 +52,8 @@ export interface PresenceUser { name: string; /** Cursor color assigned to this editor — reused for the avatar chip. */ color: string; + /** Profile picture URL, or empty when the editor has none (initials fallback). */ + avatar: string; /** True for the local editor. */ self: boolean; } @@ -93,6 +97,7 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { name: opts.user.displayName || opts.user.handle, handle: opts.user.handle, did: opts.user.did, + avatar: opts.user.avatar ?? "", color: colorForDid(opts.user.did), colorLight: `${colorForDid(opts.user.did)}33`, }); @@ -140,6 +145,7 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { handle?: string; did?: string; color?: string; + avatar?: string; }; } ).user; @@ -149,6 +155,7 @@ export function connectCollab(opts: ConnectOptions): CollabHandle { handle: user.handle, name: user.name || user.handle, color: user.color ?? "var(--accent)", + avatar: user.avatar ?? "", self: clientId === ydoc.clientID, }); } diff --git a/public/editor/editor.ts b/public/editor/editor.ts index e609d89..603ebaa 100644 --- a/public/editor/editor.ts +++ b/public/editor/editor.ts @@ -45,15 +45,21 @@ function initials(name: string): string { return (parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? ""); } -/** A single round presence chip — an editor's initials, or a "+N" overflow tally. */ +/** + * A single round presence chip — an editor's avatar (or initials / "+N" tally). + * When `avatar` is set, an covers the chip; the initials stay underneath as + * the fallback shown while it loads or if it fails (dev mode, no profile picture). + */ function avatarChip(opts: { text: string; bg: string; fg: string; title: string; first: boolean; + avatar?: string; }): HTMLSpanElement { const chip = document.createElement("span"); + chip.style.position = "relative"; chip.style.display = "inline-flex"; chip.style.alignItems = "center"; chip.style.justifyContent = "center"; @@ -68,9 +74,24 @@ function avatarChip(opts: { chip.style.lineHeight = "1"; chip.style.textTransform = "uppercase"; chip.style.userSelect = "none"; + chip.style.overflow = "hidden"; chip.style.border = "2px solid var(--bg)"; chip.title = opts.title; chip.textContent = opts.text; + + if (opts.avatar) { + const img = document.createElement("img"); + img.src = opts.avatar; + img.alt = ""; + img.style.position = "absolute"; + img.style.inset = "0"; + img.style.width = "100%"; + img.style.height = "100%"; + img.style.objectFit = "cover"; + // A broken/blocked avatar reveals the initials underneath rather than a gap. + img.onerror = () => img.remove(); + chip.appendChild(img); + } return chip; } @@ -356,6 +377,7 @@ function initEditor(root: Document | Element = document): void { const userDid = textarea.dataset["did"]; const userHandle = textarea.dataset["handle"]; const displayName = textarea.dataset["displayName"] ?? ""; + const avatar = textarea.dataset["avatar"] ?? ""; const noteHref = textarea.dataset["noteUrl"] ?? ""; const youLabel = textarea.dataset["collabYou"] ?? "you"; const liveLabel = textarea.dataset["collabLive"] ?? "Live"; @@ -394,6 +416,7 @@ function initEditor(root: Document | Element = document): void { fg: "#fff", title: u.self ? `${u.name} (${youLabel})` : `@${u.handle}`, first: i === 0, + avatar: u.avatar, }), ); } @@ -582,7 +605,7 @@ function initEditor(root: Document | Element = document): void { handle = connectCollab({ wsPath: collabWs, - user: { did: userDid, handle: userHandle, displayName }, + user: { did: userDid, handle: userHandle, displayName, avatar }, onReload: () => { if (noteHref) location.assign(noteHref); }, diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 8cd597c..aed99b2 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -101,6 +101,7 @@ export const noteRoutes = new Elysia() did: ctx.did, handle: ctx.session?.handle ?? ctx.did, displayName: "", + avatar: ctx.session?.avatar ?? "", noteUrl: noteUrl(ctx.ownerHandle, ctx.wiki.slug, noteSlug), } : undefined; diff --git a/src/views/edit-note.ts b/src/views/edit-note.ts index ea26186..91ca25f 100644 --- a/src/views/edit-note.ts +++ b/src/views/edit-note.ts @@ -13,6 +13,8 @@ export interface CollabEditOptions { did: string; handle: string; displayName: string; + /** Profile picture URL, broadcast to peers for the presence cluster. */ + avatar: string; /** Where to navigate after save/discard. */ noteUrl: string; } @@ -39,6 +41,7 @@ export function editNotePage( data-did="${escapeHtml(collab.did)}" data-handle="${escapeHtml(collab.handle)}" data-display-name="${escapeHtml(collab.displayName)}" + data-avatar="${escapeHtml(collab.avatar)}" data-note-url="${escapeHtml(collab.noteUrl)}"` : "";