From ca3bbf204eb997627d682e97a2dfd3e03235bdfb Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 12:08:34 -0700 Subject: [PATCH 01/12] fix: text compose papercuts (#270) * make fixes * cleanup --- js/desktop/package.json | 1 + .../src/components/link-preview-card.tsx | 18 ++++- .../src/features/compose/attachment-strip.tsx | 16 ++-- .../src/features/compose/markdown-editor.css | 80 ++++++++++++++++++- .../src/features/compose/markdown-editor.tsx | 33 +++++++- .../src/features/compose/text-editor.tsx | 33 ++++---- .../features/particles/text-particle-view.tsx | 31 ++----- js/desktop/src/hooks/use-file-input.ts | 20 +++-- js/desktop/src/lib/immersive-text.ts | 7 ++ js/desktop/src/lib/link-metadata.ts | 8 ++ js/desktop/src/lib/markdown.ts | 5 ++ js/desktop/src/styles/globals.css | 28 +++++++ 12 files changed, 220 insertions(+), 60 deletions(-) create mode 100644 js/desktop/src/lib/immersive-text.ts create mode 100644 js/desktop/src/lib/markdown.ts diff --git a/js/desktop/package.json b/js/desktop/package.json index 065e486..3f9e0d6 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -64,6 +64,7 @@ "@livekit/components-react": "^2.9.20", "@livekit/components-styles": "^1.2.0", "@milkdown/crepe": "^7.21.1", + "@milkdown/kit": "7.21.1", "@sentry/electron": "^7.11.0", "@sentry/react": "^10.54.0", "@tanstack/react-query": "^5.90.21", diff --git a/js/desktop/src/components/link-preview-card.tsx b/js/desktop/src/components/link-preview-card.tsx index 3e3c368..5090f97 100644 --- a/js/desktop/src/components/link-preview-card.tsx +++ b/js/desktop/src/components/link-preview-card.tsx @@ -1,5 +1,5 @@ import { Copy, ExternalLink, Globe } from 'lucide-react'; -import type { LinkMetadata } from '@/lib/link-metadata'; +import { domainFromUrl, type LinkMetadata } from '@/lib/link-metadata'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { platform } from '@/lib/platform'; @@ -93,6 +93,22 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) { ); } +/** Shown when metadata couldn't be fetched — the link itself still works. */ +export function LinkPreviewCardFallback({ url }: { url: string }) { + return ( + + ); +} + export function LinkPreviewCardSkeleton() { return (
diff --git a/js/desktop/src/features/compose/attachment-strip.tsx b/js/desktop/src/features/compose/attachment-strip.tsx index 84776b5..4c8090b 100644 --- a/js/desktop/src/features/compose/attachment-strip.tsx +++ b/js/desktop/src/features/compose/attachment-strip.tsx @@ -4,6 +4,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import type { LinkPreviewEntry } from '@/hooks/use-link-metadata'; +import { domainFromUrl } from '@/lib/link-metadata'; import { AttachmentLightbox, getAttachmentHandler, @@ -114,18 +115,19 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) { ); } - if (!entry.metadata) return null; - + // Metadata fetch can fail; fall back to the bare URL so the link stays usable. const { metadata } = entry; + const domain = metadata?.domain ?? domainFromUrl(entry.url); + const title = metadata?.title ?? entry.url; return ( diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css index 2f56fef..7227482 100644 --- a/js/desktop/src/features/compose/markdown-editor.css +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -57,6 +57,80 @@ .llink-crepe .milkdown .ProseMirror { padding: 0; outline: none; + caret-color: white; +} + +/* Crepe's default heading scale (42px h1) is sized for a document editor; + * tighten it to message-card proportions. Applies to compose and read-only + * alike so the editing view matches the posted card. */ +.llink-crepe .milkdown .ProseMirror h1 { + font-size: 1.875rem; + line-height: 2.375rem; + font-weight: 600; + margin-top: 24px; +} + +.llink-crepe .milkdown .ProseMirror h2 { + font-size: 1.5rem; + line-height: 2rem; + font-weight: 600; + margin-top: 20px; +} + +.llink-crepe .milkdown .ProseMirror h3 { + font-size: 1.25rem; + line-height: 1.75rem; + font-weight: 600; + margin-top: 16px; +} + +.llink-crepe .milkdown .ProseMirror h4 { + font-size: 1.125rem; + line-height: 1.625rem; + font-weight: 600; + margin-top: 12px; +} + +.llink-crepe .milkdown .ProseMirror h5 { + font-size: 1rem; + line-height: 1.5rem; + font-weight: 600; + margin-top: 12px; +} + +.llink-crepe .milkdown .ProseMirror h6 { + font-size: 0.875rem; + line-height: 1.375rem; + font-weight: 700; + margin-top: 12px; + color: rgb(255 255 255 / 0.7); +} + +.llink-crepe .milkdown .ProseMirror > :first-child { + margin-top: 0; +} + +/* Images come from URLs only (no stable public upload URL), so hide the + * file uploader — the placeholder then just prompts for a link. */ +.llink-crepe + .milkdown + :is(.milkdown-image-block, .milkdown-image-inline) + .placeholder + .uploader { + display: none; +} + +/* Read-only renders the image node view with inert editing chrome — hide it. */ +.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation, +.llink-crepe:not(.llink-crepe--fill) + .milkdown + .milkdown-image-block + .image-resize-handle { + display: none; +} + +.llink-crepe .milkdown .milkdown-image-block img { + border-radius: 8px; } /* Editing context: fill the compose card and scroll internally so a long @@ -71,8 +145,10 @@ } /* Pad the content (not the card) so the slash menu — which Crepe appends to - * .milkdown — can use the full card width/height before clipping. */ + * .milkdown — can use the full card width/height before clipping. The shared + * layout variables (globals.css) keep the editor's content column identical + * to the posted card's, and the gutter fits the block drag handle. */ .llink-crepe--fill .milkdown .ProseMirror { min-height: 100%; - padding: 1.25rem; + padding: var(--message-card-padding) var(--message-editor-gutter); } diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx index 7b2ce0f..43b3e00 100644 --- a/js/desktop/src/features/compose/markdown-editor.tsx +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react'; import { Crepe } from '@milkdown/crepe'; +import { editorViewCtx } from '@milkdown/kit/core'; +import { Selection } from '@milkdown/kit/prose/state'; import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/frame-dark.css'; import './markdown-editor.css'; @@ -55,13 +57,23 @@ export function MarkdownEditor({ [Crepe.Feature.BlockEdit]: !readOnly, [Crepe.Feature.Toolbar]: !readOnly, [Crepe.Feature.Placeholder]: !readOnly, - [Crepe.Feature.ImageBlock]: false, + [Crepe.Feature.ImageBlock]: true, [Crepe.Feature.Latex]: false, [Crepe.Feature.TopBar]: false, [Crepe.Feature.AI]: false, }, featureConfigs: { [Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, + // Images come from URLs only (e.g. pasted markdown) — there is no + // stable public upload URL, so the file uploader is hidden in CSS and + // onUpload rejects in case a file ever reaches it anyway (the default + // would serialize an ephemeral blob: URL into the message). + [Crepe.Feature.ImageBlock]: { + blockUploadPlaceholderText: 'Paste an image link…', + inlineUploadPlaceholderText: 'paste an image link', + maxHeight: 420, + onUpload: () => Promise.reject(new Error('Image uploads disabled')), + }, }, }); @@ -82,7 +94,18 @@ export function MarkdownEditor({ } created = crepe; if (autoFocus && !readOnly) { - root.querySelector('.ProseMirror')?.focus(); + // Place the caret at the end of the document — the editor often mounts + // mid-typing (immersive → card flip), where start-of-doc would strand + // the user. Selection-only transactions don't echo markdownUpdated. + crepe.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + view.dispatch( + view.state.tr + .setSelection(Selection.atEnd(view.state.doc)) + .scrollIntoView(), + ); + view.focus(); + }); } }); @@ -97,7 +120,11 @@ export function MarkdownEditor({ return (
); } diff --git a/js/desktop/src/features/compose/text-editor.tsx b/js/desktop/src/features/compose/text-editor.tsx index 947c0fd..2d06f80 100644 --- a/js/desktop/src/features/compose/text-editor.tsx +++ b/js/desktop/src/features/compose/text-editor.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Paperclip } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { getImmersiveTextStyle } from '@/lib/immersive-text'; +import { hasMarkdownFormatting } from '@/lib/markdown'; import { metaKey } from '@/lib/platform'; import { useAllLinkMetadata } from '@/hooks/use-link-metadata'; import { AttachmentStrip } from '@/features/compose/attachment-strip'; @@ -35,12 +37,6 @@ interface TextEditorProps { const IMMERSIVE_CHAR_LIMIT = 120; -function getImmersiveTextStyle(length: number) { - if (length < 70) return { size: 'text-5xl', weight: 'font-semibold' }; - if (length < 130) return { size: 'text-3xl', weight: 'font-semibold' }; - return { size: 'text-2xl', weight: 'font-normal' }; -} - export function TextEditor({ textContent, onTextChange, @@ -50,7 +46,9 @@ export function TextEditor({ attachmentProps, }: TextEditorProps) { const textareaRef = useRef(null); - const [forceCardMode, setForceCardMode] = useState(false); + // Card mode latches: once the message needs the full editor, snapping back + // to immersive mid-edit would be jarring, so it stays for the session. + const [carded, setCarded] = useState(false); const [debouncedText, setDebouncedText] = useState(textContent); useEffect(() => { @@ -61,13 +59,16 @@ export function TextEditor({ const attachmentCount = attachmentProps?.attachments.length ?? 0; const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0; - const immersive = - textContent.length < IMMERSIVE_CHAR_LIMIT && - !hasEnrichments && - !forceCardMode; + const shouldCard = + !carded && + (textContent.length >= IMMERSIVE_CHAR_LIMIT || + hasEnrichments || + hasMarkdownFormatting(textContent)); + if (shouldCard) setCarded(true); + const immersive = !carded; - // Keep the immersive textarea focused with the caret at the end when we - // (re)enter it. The card-mode editor manages its own focus. + // Keep the immersive textarea focused with the caret at the end on mount. + // The card-mode editor manages its own focus. useEffect(() => { if (!immersive) return; const t = setTimeout(() => { @@ -95,7 +96,7 @@ export function TextEditor({ } else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); - setForceCardMode(true); + setCarded(true); } }, [onCancel, onSubmit, textContent], @@ -127,7 +128,7 @@ export function TextEditor({ {immersive && ( setForceCardMode(true)} + onClick={() => setCarded(true)} title={`Switch to markdown editor (or press ${metaKey}+M)`} > markdown @@ -194,7 +195,7 @@ export function TextEditor({ {...dropZoneProps} onKeyDownCapture={handleKeyDown} > -
+
{/* No padding here: the editor's own scroll box hosts the slash menu, so we pad inside the editor (ProseMirror) instead. That keeps the menu's clipping bounds the full card rather than the inset box. */} diff --git a/js/desktop/src/features/particles/text-particle-view.tsx b/js/desktop/src/features/particles/text-particle-view.tsx index 47b807a..95607fb 100644 --- a/js/desktop/src/features/particles/text-particle-view.tsx +++ b/js/desktop/src/features/particles/text-particle-view.tsx @@ -8,8 +8,11 @@ import { type LinkPreviewEntry, } from '@/hooks/use-link-metadata'; import { extractUrls } from '@/lib/link-metadata'; +import { getImmersiveTextStyle } from '@/lib/immersive-text'; +import { hasMarkdownFormatting } from '@/lib/markdown'; import { LinkPreviewCard, + LinkPreviewCardFallback, LinkPreviewCardSkeleton, } from '@/components/link-preview-card'; import { useParticleAttachments } from '@/hooks/use-particle-attachments'; @@ -51,18 +54,6 @@ function computeReadDuration( return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S); } -function getImmersiveTextStyle(length: number) { - if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' }; - if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' }; - return { size: 'text-2xl', weight: 'font-normal' }; -} - -function hasMarkdownFormatting(content: string): boolean { - return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test( - content, - ); -} - function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { return (
@@ -72,7 +63,9 @@ function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) { ) : entry.metadata ? ( - ) : null} + ) : ( + + )}
))}
@@ -209,17 +202,7 @@ export function TextParticleView({ // Mode 3: card layout return (
-
+
{ if (!enabled) return; + // Capture phase so file pastes always become attachments — ProseMirror + // would otherwise inline pasted images as ephemeral blob: URLs. Mixed + // clipboards (e.g. Excel/Word ship an image rendition alongside the text) + // must still paste as text, so only file-only pastes are intercepted. const handlePaste = (e: ClipboardEvent) => { - const files = Array.from(e.clipboardData?.files ?? []); - if (files.length > 0) { - e.preventDefault(); - onFilesRef.current(files); - } + const data = e.clipboardData; + if (!data) return; + const files = Array.from(data.files); + if (files.length === 0 || data.types.includes('text/plain')) return; + e.preventDefault(); + e.stopPropagation(); + onFilesRef.current(files); }; - window.addEventListener('paste', handlePaste); - return () => window.removeEventListener('paste', handlePaste); + window.addEventListener('paste', handlePaste, true); + return () => window.removeEventListener('paste', handlePaste, true); }, [enabled]); // Drag and drop handlers diff --git a/js/desktop/src/lib/immersive-text.ts b/js/desktop/src/lib/immersive-text.ts new file mode 100644 index 0000000..529ba1e --- /dev/null +++ b/js/desktop/src/lib/immersive-text.ts @@ -0,0 +1,7 @@ +/** Font scale for short messages shown as large centered text. Shared by the + * composer and the posted view so editing matches the final render. */ +export function getImmersiveTextStyle(length: number) { + if (length < 30) return { size: 'text-5xl', weight: 'font-semibold' }; + if (length < 70) return { size: 'text-3xl', weight: 'font-semibold' }; + return { size: 'text-2xl', weight: 'font-normal' }; +} diff --git a/js/desktop/src/lib/link-metadata.ts b/js/desktop/src/lib/link-metadata.ts index 0bdbc9d..3447ec0 100644 --- a/js/desktop/src/lib/link-metadata.ts +++ b/js/desktop/src/lib/link-metadata.ts @@ -14,3 +14,11 @@ export function extractUrls(text: string): string[] { // should only yield a single preview card. return Array.from(new Set(Array.from(text.matchAll(URL_REGEX), (m) => m[0]))); } + +export function domainFromUrl(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} diff --git a/js/desktop/src/lib/markdown.ts b/js/desktop/src/lib/markdown.ts new file mode 100644 index 0000000..eb188a3 --- /dev/null +++ b/js/desktop/src/lib/markdown.ts @@ -0,0 +1,5 @@ +export function hasMarkdownFormatting(content: string): boolean { + return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*\S[^*]*\*|\b_\S[^_]*_\b|^>/m.test( + content, + ); +} diff --git a/js/desktop/src/styles/globals.css b/js/desktop/src/styles/globals.css index 9773c25..b63b917 100644 --- a/js/desktop/src/styles/globals.css +++ b/js/desktop/src/styles/globals.css @@ -151,6 +151,34 @@ background: oklch(1 0 0 / 35%); } +/* Message cards: more prominent scrollbar than the subtle global default. + The descendant form reaches scroll containers we don't own (Crepe's .milkdown). */ +.scrollbar-card::-webkit-scrollbar, +.scrollbar-card ::-webkit-scrollbar { + width: 8px; +} + +.scrollbar-card::-webkit-scrollbar-thumb, +.scrollbar-card ::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 30%); + border-radius: 9999px; +} + +.scrollbar-card::-webkit-scrollbar-thumb:hover, +.scrollbar-card ::-webkit-scrollbar-thumb:hover { + background: oklch(1 0 0 / 50%); +} + +/* Message layout: the compose editor and the posted card derive their widths + from one shared content column, so line wrapping while editing matches the + final render. The editor gutter additionally fits Crepe's block drag handle + (66px wide, offset 16px left of the block). */ +:root { + --message-content-width: 39rem; + --message-card-padding: 1.5rem; + --message-editor-gutter: 5.5rem; +} + /* Frameless window drag regions */ .drag-region { -webkit-app-region: drag; -- 2.54.0 From 78c5151f3bf1fb4e95438d92f3cb7c5b9dadec9f Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 13:47:34 -0700 Subject: [PATCH 02/12] implement avatar backend functionality --- go/cmd/orion/main.go | 5 +- go/internal/handler/handler.go | 72 +++++++++++++++++++++- go/internal/human/models.go | 1 + go/internal/human/repository.go | 27 ++++++-- go/internal/human/service.go | 26 +++++++- go/internal/human/service_test.go | 15 +++++ go/migrations/000017_human_avatar.down.sql | 6 ++ go/migrations/000017_human_avatar.up.sql | 6 ++ 8 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 go/migrations/000017_human_avatar.down.sql create mode 100644 go/migrations/000017_human_avatar.up.sql diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 09ec9fd..324980c 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -148,6 +148,9 @@ func main() { // Settings mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) + // Get avatar download url + mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl)) + // Push notification tokens (per-device) mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken)) mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken)) @@ -174,7 +177,7 @@ func main() { mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation)) // Particles - mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) + mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl)) // Link metadata mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata)) diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 0ec211a..cdf23b9 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -73,6 +73,7 @@ type Human struct { Email string `json:"email"` EmailPrefix string `json:"email_prefix"` EmailNotificationsEnabled bool `json:"email_notifications_enabled"` + AvatarObjectID *string `json:"avatar_object_id"` CreatedAt time.Time `json:"created_at"` } @@ -334,6 +335,72 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + err := h.humanSvc.DeleteAvatar(r.Context(), humanId) + if err != nil { + flog.Error("failed to delete avatar from human", "error", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // best-effort, gracefully continue + human, err := h.humanSvc.GetByID(r.Context(), humanId) + if err != nil { + flog.Error("failed to get human by id", "error", err, "humanId", humanId) + w.WriteHeader(http.StatusNoContent) + return + } + + if human.AvatarObjectID != nil { + err = h.depotSvc.Delete(r.Context(), utils.OptionalString(human.AvatarObjectID)) + if err != nil { + flog.Error("failed to delete object", "error", err, "objectID", human.AvatarObjectID) + } + } + + w.WriteHeader(http.StatusNoContent) + return +} + +func (h *Handler) UpdateAvatar(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // 5MB limit = 5 * 1024 * 1024 bytes + const maxBodySize = 5 << 20 + + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + + object, err := h.depotSvc.CreateFromReader(r.Context(), depot.CreateFromReaderInput{ + Prefix: "avatars", + Name: fmt.Sprintf("%s-avatar", humanId), + ContentType: r.Header.Get("Content-Type"), + }, r.Body) + if err != nil { + flog.Error("failed to upload avatar with depo", "error", err, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + err = h.humanSvc.UpdateAvatar(r.Context(), humanId, object.ID) + if err != nil { + flog.Error("failed to update human avatar", "error", err, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + // ============================================================================ // Network Handlers // ============================================================================ @@ -717,8 +784,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// DownloadParticleMedia returns a fresh signed URL for media/file particles. -func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) { +// GetObjectDownloadUrl returns a fresh signed URL for media/file particles. +func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -1007,6 +1074,7 @@ func humanToDTO(h *human.Human) Human { Email: h.Email, EmailPrefix: h.EmailPrefix, EmailNotificationsEnabled: h.EmailNotificationsEnabled, + AvatarObjectID: h.AvatarObjectID, CreatedAt: h.CreatedAt, } } diff --git a/go/internal/human/models.go b/go/internal/human/models.go index a17f592..c264996 100644 --- a/go/internal/human/models.go +++ b/go/internal/human/models.go @@ -7,6 +7,7 @@ type Human struct { Email string EmailPrefix string EmailNotificationsEnabled bool + AvatarObjectID *string LastEmailNotificationSentAt *time.Time CreatedAt time.Time } diff --git a/go/internal/human/repository.go b/go/internal/human/repository.go index 8045cfc..8b3b7b1 100644 --- a/go/internal/human/repository.go +++ b/go/internal/human/repository.go @@ -37,6 +37,7 @@ type repository interface { listAll(ctx context.Context) ([]*Human, error) updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error + updateAvatarObjectID(ctx context.Context, id string, objectID *string) error } type repositoryImpl struct { @@ -50,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository { func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) { var h Human err := r.pool.QueryRow(ctx, - `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`, + `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE email = $1`, email, - ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt) + ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errNotFound @@ -66,9 +67,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) { var h Human err := r.pool.QueryRow(ctx, - `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`, + `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`, id, - ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt) + ).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errNotFound @@ -113,7 +114,7 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error) func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) { rows, err := r.pool.Query(ctx, - `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`, + `SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`, ) if err != nil { return nil, err @@ -123,7 +124,7 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) { var humans []*Human for rows.Next() { var h Human - if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil { + if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID); err != nil { return nil, err } h.EmailPrefix = emailPrefix(h.Email) @@ -159,3 +160,17 @@ func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context, } return nil } + +func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error { + result, err := r.pool.Exec(ctx, + `UPDATE humans SET avatar_object_id = $2 WHERE id = $1`, + id, objectID, + ) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return errNotFound + } + return nil +} diff --git a/go/internal/human/service.go b/go/internal/human/service.go index 1a28421..eb094b7 100644 --- a/go/internal/human/service.go +++ b/go/internal/human/service.go @@ -11,7 +11,10 @@ import ( //go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go -var ErrNotFound = errors.New("human not found") +var ( + ErrNotFound = errors.New("human not found") + ErrInvalidParam = errors.New("invalid param") +) type Service interface { GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) @@ -22,6 +25,8 @@ type Service interface { ListAll(ctx context.Context) ([]*Human, error) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error + UpdateAvatar(ctx context.Context, id string, objectID string) error + DeleteAvatar(ctx context.Context, id string) error } type serviceImpl struct { @@ -88,3 +93,22 @@ func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id } return err } + +func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error { + if objectID == "" { + return ErrInvalidParam + } + err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID)) + if errors.Is(err, errNotFound) { + return ErrNotFound + } + return err +} + +func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error { + err := s.repo.updateAvatarObjectID(ctx, id, nil) + if errors.Is(err, errNotFound) { + return ErrNotFound + } + return err +} diff --git a/go/internal/human/service_test.go b/go/internal/human/service_test.go index 89154a6..d0a3402 100644 --- a/go/internal/human/service_test.go +++ b/go/internal/human/service_test.go @@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) { assert.NotEqual(t, createdHuman.ID, anotherHuman.ID) assert.Equal(t, "another@example.com", anotherHuman.Email) assert.Equal(t, "another", anotherHuman.EmailPrefix) + + // Test avatar handling + objectID := "obj_xxx" + err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID) + assert.NoError(t, err) + + anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID) + assert.NoError(t, err) + assert.Equal(t, objectID, *anotherHuman.AvatarObjectID) + + err = svc.DeleteAvatar(ctx, anotherHuman.ID) + assert.NoError(t, err) + anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID) + assert.NoError(t, err) + assert.Nil(t, anotherHuman.AvatarObjectID) } diff --git a/go/migrations/000017_human_avatar.down.sql b/go/migrations/000017_human_avatar.down.sql new file mode 100644 index 0000000..3eaffc5 --- /dev/null +++ b/go/migrations/000017_human_avatar.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE humans + DROP COLUMN IF EXISTS avatar_object_id, + +COMMIT; diff --git a/go/migrations/000017_human_avatar.up.sql b/go/migrations/000017_human_avatar.up.sql new file mode 100644 index 0000000..5144d9b --- /dev/null +++ b/go/migrations/000017_human_avatar.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE humans + ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL; + +COMMIT; -- 2.54.0 From 81f376cf62b84e83ce67369562a5b5d333fe810e Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 13:55:09 -0700 Subject: [PATCH 03/12] add avatar endpoints --- go/cmd/orion/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 324980c..0c5d1ef 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -147,8 +147,10 @@ func main() { // Settings mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) + mux.HANDLE("PUT /humans/me/avatar", withAuth(h.UpdateAvatar)) + mux.HANDLE("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar)) - // Get avatar download url + // Get avatar download url, given objectId mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl)) // Push notification tokens (per-device) -- 2.54.0 From 71fe83bd0f1c969ecf323ae73b00241f3c474b3a Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 13:57:08 -0700 Subject: [PATCH 04/12] typo --- go/cmd/orion/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 0c5d1ef..5587ac5 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -147,8 +147,8 @@ func main() { // Settings mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) - mux.HANDLE("PUT /humans/me/avatar", withAuth(h.UpdateAvatar)) - mux.HANDLE("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar)) + mux.Handle("PUT /humans/me/avatar", withAuth(h.UpdateAvatar)) + mux.Handle("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar)) // Get avatar download url, given objectId mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl)) -- 2.54.0 From c4e6cfa29b81b42a11ad62ea8018f7ffa352d702 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 14:49:48 -0700 Subject: [PATCH 05/12] implement client side avatar upload and handling --- js/desktop/src/api/client.ts | 42 ++- js/desktop/src/api/types.ts | 1 + js/desktop/src/components/human-avatar.tsx | 34 ++ js/desktop/src/features/network-settings.tsx | 11 +- .../features/particles/particle-list-view.tsx | 34 +- .../particles/playback-page-indicator.tsx | 12 +- .../src/features/particles/reaction-bar.tsx | 14 +- .../src/features/particles/stream-card.tsx | 34 +- .../particles/stream-members-overlay.tsx | 24 +- .../src/features/particles/stream-top-bar.tsx | 35 +- js/desktop/src/features/settings-page.tsx | 28 +- .../features/settings/avatar-edit-dialog.tsx | 337 ++++++++++++++++++ js/desktop/src/hooks/use-avatar-url.ts | 20 ++ .../src/hooks/use-presence-positions.ts | 2 + js/desktop/src/lib/avatar-image.ts | 28 ++ js/desktop/src/lib/humans.ts | 4 + js/desktop/src/stores/auth-store.ts | 6 + 17 files changed, 588 insertions(+), 78 deletions(-) create mode 100644 js/desktop/src/components/human-avatar.tsx create mode 100644 js/desktop/src/features/settings/avatar-edit-dialog.tsx create mode 100644 js/desktop/src/hooks/use-avatar-url.ts create mode 100644 js/desktop/src/lib/avatar-image.ts diff --git a/js/desktop/src/api/client.ts b/js/desktop/src/api/client.ts index f62ab6b..04aefbb 100644 --- a/js/desktop/src/api/client.ts +++ b/js/desktop/src/api/client.ts @@ -42,16 +42,12 @@ class ApiClient { this.config = config; } - private async fetch( + private async send( method: string, path: string, - body?: unknown, + init: { headers?: Record; body?: BodyInit } = {}, ): Promise { - const headers: Record = {}; - - if (body) { - headers['Content-Type'] = 'application/json'; - } + const headers: Record = { ...init.headers }; const token = this.config.getToken(); if (token) { @@ -61,7 +57,7 @@ class ApiClient { const response = await fetch(`${this.config.baseUrl}${path}`, { method, headers, - body: body ? JSON.stringify(body) : undefined, + body: init.body, }); if (response.status === 401) { @@ -77,6 +73,17 @@ class ApiClient { return response; } + private async fetch( + method: string, + path: string, + body?: unknown, + ): Promise { + return this.send(method, path, { + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } + private async request( schema: z.ZodType, method: string, @@ -137,6 +144,25 @@ class ApiClient { await this.requestVoid('PATCH', '/humans/me/settings', data); } + // --- Avatar --- + + async updateAvatar(blob: Blob): Promise { + await this.send('PUT', '/humans/me/avatar', { + headers: { 'Content-Type': blob.type || 'image/jpeg' }, + body: blob, + }); + } + + async deleteAvatar(): Promise { + await this.requestVoid('DELETE', '/humans/me/avatar'); + } + + async getAvatarDownloadUrl(objectId: string): Promise { + const response = await this.fetch('GET', `/humans/avatar/${objectId}`); + const data = await response.json(); + return data.url; + } + // --- Depot --- async prepareUpload(data: PrepareUploadRequest) { diff --git a/js/desktop/src/api/types.ts b/js/desktop/src/api/types.ts index 7410cbb..ab63df4 100644 --- a/js/desktop/src/api/types.ts +++ b/js/desktop/src/api/types.ts @@ -6,6 +6,7 @@ export const HumanSchema = z.object({ email: z.string().email(), email_prefix: z.string(), email_notifications_enabled: z.boolean(), + avatar_object_id: z.string().nullable().optional(), }); export type Human = z.infer; diff --git a/js/desktop/src/components/human-avatar.tsx b/js/desktop/src/components/human-avatar.tsx new file mode 100644 index 0000000..9f4d982 --- /dev/null +++ b/js/desktop/src/components/human-avatar.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; + +interface HumanAvatarProps + extends Omit, 'children'> { + /** Object id of the human's profile picture, if any. */ + avatarObjectId?: string | null; + /** Initials rendered while loading or when no picture is set. */ + initials: string; + /** Extra classes for the initials fallback. */ + fallbackClassName?: string; +} + +/** + * Renders a human's avatar: their profile picture when set (resolved to a + * signed URL), otherwise their initials. The fallback also shows while the + * image loads or if it fails, so this is a drop-in for the initials-only + * usages throughout the app. + */ +export function HumanAvatar({ + avatarObjectId, + initials, + fallbackClassName, + ...props +}: HumanAvatarProps) { + const url = useAvatarUrl(avatarObjectId); + return ( + + {url && } + {initials} + + ); +} diff --git a/js/desktop/src/features/network-settings.tsx b/js/desktop/src/features/network-settings.tsx index 76586ee..c2134e3 100644 --- a/js/desktop/src/features/network-settings.tsx +++ b/js/desktop/src/features/network-settings.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -45,11 +46,11 @@ function MemberRow({ return (
- - - {initials} - - +

{human.email_prefix}

{human.email} diff --git a/js/desktop/src/features/particles/particle-list-view.tsx b/js/desktop/src/features/particles/particle-list-view.tsx index 7653013..2b64574 100644 --- a/js/desktop/src/features/particles/particle-list-view.tsx +++ b/js/desktop/src/features/particles/particle-list-view.tsx @@ -26,7 +26,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { particlePath } from '@/lib/particle-path'; import { resolveHumanDisplay } from '@/lib/humans'; import { RelativeTimestamp } from '@/components/relative-timestamp'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Separator } from '@/components/ui/separator'; import { Progress } from '@/components/ui/progress'; import { Small } from '@/components/ui/typography'; @@ -150,7 +150,7 @@ const StreamRow = memo(function StreamRow({ particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith('human:')); - const initials = useMemo(() => { + const avatar = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userId}`, @@ -158,7 +158,12 @@ const StreamRow = memo(function StreamRow({ if (otherEntry) { const otherId = otherEntry.replace('human:', ''); const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); + if (otherHuman) { + return { + initials: getInitials(otherHuman.email), + avatarObjectId: otherHuman.avatar_object_id ?? null, + }; + } } } @@ -166,10 +171,18 @@ const StreamRow = memo(function StreamRow({ const creator = network?.humans?.find( (h) => h.id === latestChild.created_by_human_id, ); - if (creator) return getInitials(creator.email); + if (creator) { + return { + initials: getInitials(creator.email), + avatarObjectId: creator.avatar_object_id ?? null, + }; + } } - return particle.properties.name.slice(0, 2).toUpperCase(); + return { + initials: particle.properties.name.slice(0, 2).toUpperCase(), + avatarObjectId: null, + }; }, [ isDM, particle.visible_to, @@ -242,11 +255,12 @@ const StreamRow = memo(function StreamRow({ {videoThumbObjectId ? ( ) : ( - - - {initials} - - + )}
diff --git a/js/desktop/src/features/particles/playback-page-indicator.tsx b/js/desktop/src/features/particles/playback-page-indicator.tsx index 49f860f..71879a0 100644 --- a/js/desktop/src/features/particles/playback-page-indicator.tsx +++ b/js/desktop/src/features/particles/playback-page-indicator.tsx @@ -1,4 +1,4 @@ -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Tooltip, TooltipContent, @@ -161,18 +161,16 @@ function SegmentPresenceAvatars({ {visible.map((human) => ( - - - {human.emailPrefix.slice(0, 2).toUpperCase()} - - + avatarObjectId={human.avatarObjectId} + initials={human.emailPrefix.slice(0, 2).toUpperCase()} + /> {human.email} diff --git a/js/desktop/src/features/particles/reaction-bar.tsx b/js/desktop/src/features/particles/reaction-bar.tsx index 8671f9a..339470f 100644 --- a/js/desktop/src/features/particles/reaction-bar.tsx +++ b/js/desktop/src/features/particles/reaction-bar.tsx @@ -5,7 +5,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; import { cn } from '@/lib/utils'; import { resolveHumanDisplay } from '@/lib/humans'; @@ -113,11 +113,13 @@ export function ReactionBar({ : 'bg-black/40 hover:bg-black/50', )} > - - - {firstReactor.initials} - - + {text} {reactors.length > 1 && ( diff --git a/js/desktop/src/features/particles/stream-card.tsx b/js/desktop/src/features/particles/stream-card.tsx index d284f12..e133913 100644 --- a/js/desktop/src/features/particles/stream-card.tsx +++ b/js/desktop/src/features/particles/stream-card.tsx @@ -8,7 +8,7 @@ import type { Particle, StreamProperties } from '@/api/types'; import { useStreamAutoplay } from '@/hooks/use-stream-autoplay'; import { ParticlePreview } from '@/features/particles/particle-preview'; import { useNetwork } from '@/hooks/use-networks'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { RelativeTimestamp } from '@/components/relative-timestamp'; import { Small } from '@/components/ui/typography'; @@ -41,7 +41,7 @@ export const StreamCard = forwardRef( particle.visible_to.length === 2 && particle.visible_to.every((v) => v.startsWith('human:')); - const initials = useMemo(() => { + const avatar = useMemo(() => { if (isDM) { const otherEntry = particle.visible_to.find( (v) => v !== `human:${userId}`, @@ -49,7 +49,12 @@ export const StreamCard = forwardRef( if (otherEntry) { const otherId = otherEntry.replace('human:', ''); const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); + if (otherHuman) { + return { + initials: getInitials(otherHuman.email), + avatarObjectId: otherHuman.avatar_object_id ?? null, + }; + } } } @@ -57,10 +62,18 @@ export const StreamCard = forwardRef( const creator = network?.humans?.find( (h) => h.id === latestChild.created_by_human_id, ); - if (creator) return getInitials(creator.email); + if (creator) { + return { + initials: getInitials(creator.email), + avatarObjectId: creator.avatar_object_id ?? null, + }; + } } - return particle.properties.name.slice(0, 2).toUpperCase(); + return { + initials: particle.properties.name.slice(0, 2).toUpperCase(), + avatarObjectId: null, + }; }, [ isDM, particle.visible_to, @@ -132,13 +145,12 @@ export const StreamCard = forwardRef( {/* Info bar */}
- - - {initials} - - + avatarObjectId={avatar.avatarObjectId} + initials={avatar.initials} + fallbackClassName="bg-primary/10 text-primary text-[10px] font-medium" + /> - - - {display.initials} - - + - - - {getInitials(human.email)} - - + {human.email_prefix} diff --git a/js/desktop/src/features/particles/stream-top-bar.tsx b/js/desktop/src/features/particles/stream-top-bar.tsx index e5cca4b..0985ed5 100644 --- a/js/desktop/src/features/particles/stream-top-bar.tsx +++ b/js/desktop/src/features/particles/stream-top-bar.tsx @@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth-store'; import { apiClient } from '@/api/client'; import { isParticleDeleted, type Particle } from '@/api/types'; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; -import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar'; +import { AvatarGroup } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { Tooltip, TooltipContent, @@ -147,11 +148,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { return ( - - - {display.initials} - - + {display.email} @@ -313,11 +315,13 @@ function MembersIndicator({ <> {shownMembers.map((human) => ( - - - {resolveHumanDisplay(human.id, humans).initials} - - + ))} {overflow > 0 && ( @@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({ return ( - - {display.initials} - + {display.displayName} - ); diff --git a/js/desktop/src/features/settings-page.tsx b/js/desktop/src/features/settings-page.tsx index b5cec58..f8dc62f 100644 --- a/js/desktop/src/features/settings-page.tsx +++ b/js/desktop/src/features/settings-page.tsx @@ -11,8 +11,10 @@ import { FileText, Volume2, ArrowLeft, + Camera, } from 'lucide-react'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; +import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { WindowControls } from '@/components/window-controls'; @@ -88,6 +90,7 @@ export default function SettingsPage() { const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled); const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled); const [version, setVersion] = useState(); + const [avatarOpen, setAvatarOpen] = useState(false); useEffect(() => { platform.app.getVersion().then(setVersion); @@ -135,17 +138,30 @@ export default function SettingsPage() { {/* Profile header */}
- - - {initials} - - +

{user?.email_prefix}

{user?.email}
+ + diff --git a/js/desktop/src/features/settings/avatar-edit-dialog.tsx b/js/desktop/src/features/settings/avatar-edit-dialog.tsx new file mode 100644 index 0000000..2bdd767 --- /dev/null +++ b/js/desktop/src/features/settings/avatar-edit-dialog.tsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Camera, Loader2, Trash2, Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Muted } from '@/components/ui/typography'; +import { apiClient } from '@/api/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; +import { useFileInput } from '@/hooks/use-file-input'; +import { toAvatarBlob } from '@/lib/avatar-image'; +import { logError, toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +// Guard the *source* file before decoding so we never load a huge image into +// memory just to throw most of it away — the uploaded blob is always our small +// re-encoded square regardless of input size. +const MAX_SOURCE_BYTES = 30 * 1024 * 1024; + +interface AvatarEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) { + const user = useAuthStore((s) => s.user); + const refreshUser = useAuthStore((s) => s.refreshUser); + const currentUrl = useAvatarUrl(user?.avatar_object_id); + const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?'; + const hasAvatar = !!user?.avatar_object_id; + + const [tab, setTab] = useState<'upload' | 'camera'>('upload'); + const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>( + null, + ); + const [busy, setBusy] = useState(false); + + // Keep the latest prepared blob in a ref so the unmount cleanup can revoke + // its object URL without re-running on every change. + const preparedRef = useRef(prepared); + useEffect(() => { + preparedRef.current = prepared; + }, [prepared]); + useEffect( + () => () => { + if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url); + }, + [], + ); + + const setPreparedFromBlob = useCallback((blob: Blob) => { + setPrepared((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return { blob, url: URL.createObjectURL(blob) }; + }); + }, []); + + // Close and reset to a clean slate so reopening starts fresh. + const close = useCallback(() => { + setPrepared((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return null; + }); + setTab('upload'); + setBusy(false); + onOpenChange(false); + }, [onOpenChange]); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) onOpenChange(true); + else close(); + }, + [close, onOpenChange], + ); + + const handleFiles = useCallback( + async (files: File[]) => { + const file = files[0]; + if (!file) return; + if (!file.type.startsWith('image/')) { + toast.error('Please choose an image file.'); + return; + } + if (file.size > MAX_SOURCE_BYTES) { + toast.error('That image is too large — choose one under 30MB.'); + return; + } + try { + const bitmap = await createImageBitmap(file); + const blob = await toAvatarBlob(bitmap); + bitmap.close(); + setPreparedFromBlob(blob); + } catch (err) { + toast.error('Could not process that image.'); + logError(err, { scope: 'avatar.processFile' }); + } + }, + [setPreparedFromBlob], + ); + + const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ + onFilesSelected: handleFiles, + enabled: open && tab === 'upload', + }); + + const handleSave = async () => { + if (!prepared) return; + setBusy(true); + try { + await apiClient.updateAvatar(prepared.blob); + await refreshUser(); + toast.success('Avatar updated'); + close(); + } catch (err) { + toast.error(toUserMessage(err)); + logError(err, { scope: 'avatar.save' }); + setBusy(false); + } + }; + + const handleRemove = async () => { + setBusy(true); + try { + await apiClient.deleteAvatar(); + await refreshUser(); + toast.success('Avatar removed'); + close(); + } catch (err) { + toast.error(toUserMessage(err)); + logError(err, { scope: 'avatar.remove' }); + setBusy(false); + } + }; + + const previewUrl = prepared?.url ?? currentUrl; + + return ( + + + + Profile picture + + Upload an image or take one with your camera. + + + +
+
+ {previewUrl ? ( + + ) : ( + + {initials} + + )} +
+ + setTab(v as 'upload' | 'camera')} + className="w-full" + > + + + + Upload + + + + Take photo + + + + +
+ Drag an image here, or + +
+
+ + + + +
+
+ + + {hasAvatar && ( + + )} + + + +
+
+ ); +} + +function CameraCapture({ + active, + onCapture, +}: { + active: boolean; + onCapture: (blob: Blob) => void; +}) { + const videoRef = useRef(null); + const [stream, setStream] = useState(null); + const [error, setError] = useState(null); + const [capturing, setCapturing] = useState(false); + + useEffect(() => { + if (!active) return; + + let cancelled = false; + let acquired: MediaStream | null = null; + + navigator.mediaDevices + .getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false }) + .then((s) => { + if (cancelled) { + s.getTracks().forEach((t) => t.stop()); + return; + } + acquired = s; + setStream(s); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError( + err instanceof Error ? err.message : 'Unable to access camera', + ); + }); + + return () => { + cancelled = true; + acquired?.getTracks().forEach((t) => t.stop()); + setStream(null); + }; + }, [active]); + + useEffect(() => { + if (videoRef.current) videoRef.current.srcObject = stream; + }, [stream]); + + const handleCapture = async () => { + const video = videoRef.current; + if (!video) return; + setCapturing(true); + try { + const bitmap = await createImageBitmap(video); + // Mirror to match the (mirrored) live preview the user is looking at. + const blob = await toAvatarBlob(bitmap, { mirror: true }); + bitmap.close(); + onCapture(blob); + } catch (err) { + toast.error('Could not capture photo.'); + logError(err, { scope: 'avatar.capture' }); + } finally { + setCapturing(false); + } + }; + + if (error) { + return ( +
+ Couldn't access your camera. + {error} +
+ ); + } + + return ( +
+
+
+ +
+ ); +} diff --git a/js/desktop/src/hooks/use-avatar-url.ts b/js/desktop/src/hooks/use-avatar-url.ts new file mode 100644 index 0000000..5984366 --- /dev/null +++ b/js/desktop/src/hooks/use-avatar-url.ts @@ -0,0 +1,20 @@ +import { useQuery, skipToken } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; + +/** + * Resolves an avatar object id to a signed download URL. Mirrors + * {@link import('./use-download-url').useDownloadUrl} — React Query handles + * caching and de-duping, so many avatars sharing an id make a single request. + */ +export function useAvatarUrl( + objectId: string | null | undefined, +): string | undefined { + const { data } = useQuery({ + queryKey: ['avatar-url', objectId], + queryFn: objectId + ? () => apiClient.getAvatarDownloadUrl(objectId) + : skipToken, + staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h + }); + return data; +} diff --git a/js/desktop/src/hooks/use-presence-positions.ts b/js/desktop/src/hooks/use-presence-positions.ts index 3ad9ea7..13d136b 100644 --- a/js/desktop/src/hooks/use-presence-positions.ts +++ b/js/desktop/src/hooks/use-presence-positions.ts @@ -5,6 +5,7 @@ export interface HumanPresence { humanId: string; email: string; emailPrefix: string; + avatarObjectId: string | null; } /** @@ -42,6 +43,7 @@ export function usePresencePositions( humanId: human.id, email: human.email, emailPrefix: human.email_prefix, + avatarObjectId: human.avatar_object_id ?? null, }; if (existing) { existing.push(presence); diff --git a/js/desktop/src/lib/avatar-image.ts b/js/desktop/src/lib/avatar-image.ts new file mode 100644 index 0000000..14c41a8 --- /dev/null +++ b/js/desktop/src/lib/avatar-image.ts @@ -0,0 +1,28 @@ +/** + * Center-crop an image source to a square and downscale it to a JPEG suitable + * for an avatar. Avatars never render larger than ~80px, so 512px is generous + * headroom while keeping the upload to a few tens of KB regardless of input. + * + * Pass `mirror` when capturing from a (mirrored) webcam preview so the saved + * image matches what the user saw. + */ +export async function toAvatarBlob( + source: ImageBitmap, + { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {}, +): Promise { + const side = Math.min(source.width, source.height); + const sx = (source.width - side) / 2; + const sy = (source.height - side) / 2; + + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to acquire 2D canvas context'); + + if (mirror) { + ctx.translate(size, 0); + ctx.scale(-1, 1); + } + ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size); + + return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 }); +} diff --git a/js/desktop/src/lib/humans.ts b/js/desktop/src/lib/humans.ts index c25284c..2e710a8 100644 --- a/js/desktop/src/lib/humans.ts +++ b/js/desktop/src/lib/humans.ts @@ -13,6 +13,8 @@ export interface HumanDisplay { email: string; /** Initials for avatar fallback. */ initials: string; + /** Avatar object id, when the human has a profile picture set. */ + avatarObjectId: string | null; } /** @@ -32,6 +34,7 @@ export function resolveHumanDisplay( displayName: REMOVED_MEMBER_LABEL, email: REMOVED_MEMBER_LABEL, initials: REMOVED_MEMBER_INITIALS, + avatarObjectId: null, }; } return { @@ -39,5 +42,6 @@ export function resolveHumanDisplay( displayName: human.email_prefix, email: human.email, initials: getInitials(human.email), + avatarObjectId: human.avatar_object_id ?? null, }; } diff --git a/js/desktop/src/stores/auth-store.ts b/js/desktop/src/stores/auth-store.ts index 6a46974..9769cb0 100644 --- a/js/desktop/src/stores/auth-store.ts +++ b/js/desktop/src/stores/auth-store.ts @@ -30,6 +30,7 @@ interface AuthState { isSigningOut: boolean; error: string | null; restoreSession: () => Promise; + refreshUser: () => Promise; requestCode: (email: string) => Promise; signIn: (email: string, code: string) => Promise; signOut: () => Promise; @@ -64,6 +65,11 @@ export const useAuthStore = create((set) => ({ } }, + refreshUser: async () => { + const user = await apiClient.me(); + set({ user }); + }, + requestCode: async (email: string) => { set({ isRequestingCode: true, error: null }); try { -- 2.54.0 From fa9d54aab5ba3fc28c8c8930154394e653515576 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 15:03:33 -0700 Subject: [PATCH 06/12] fixes --- js/desktop/src/components/human-avatar.tsx | 6 ++- .../features/settings/avatar-edit-dialog.tsx | 43 ++++++++++--------- js/desktop/src/lib/avatar-image.ts | 5 +++ 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/js/desktop/src/components/human-avatar.tsx b/js/desktop/src/components/human-avatar.tsx index 9f4d982..865efee 100644 --- a/js/desktop/src/components/human-avatar.tsx +++ b/js/desktop/src/components/human-avatar.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useAvatarUrl } from '@/hooks/use-avatar-url'; -interface HumanAvatarProps - extends Omit, 'children'> { +interface HumanAvatarProps extends Omit< + React.ComponentProps, + 'children' +> { /** Object id of the human's profile picture, if any. */ avatarObjectId?: string | null; /** Initials rendered while loading or when no picture is set. */ diff --git a/js/desktop/src/features/settings/avatar-edit-dialog.tsx b/js/desktop/src/features/settings/avatar-edit-dialog.tsx index 2bdd767..14f23a6 100644 --- a/js/desktop/src/features/settings/avatar-edit-dialog.tsx +++ b/js/desktop/src/features/settings/avatar-edit-dialog.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'; import { Muted } from '@/components/ui/typography'; import { apiClient } from '@/api/client'; import { useAuthStore } from '@/stores/auth-store'; +import { useMediaDevicesStore } from '@/stores/media-devices-store'; import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { useFileInput } from '@/hooks/use-file-input'; import { toAvatarBlob } from '@/lib/avatar-image'; @@ -30,7 +31,10 @@ interface AvatarEditDialogProps { onOpenChange: (open: boolean) => void; } -export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) { +export function AvatarEditDialog({ + open, + onOpenChange, +}: AvatarEditDialogProps) { const user = useAuthStore((s) => s.user); const refreshUser = useAuthStore((s) => s.refreshUser); const currentUrl = useAvatarUrl(user?.avatar_object_id); @@ -156,11 +160,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps)
{previewUrl ? ( - + ) : ( {initials} @@ -221,12 +221,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) Remove )} -
- diff --git a/js/desktop/src/lib/avatar-image.ts b/js/desktop/src/lib/avatar-image.ts index 14c41a8..2fc7aac 100644 --- a/js/desktop/src/lib/avatar-image.ts +++ b/js/desktop/src/lib/avatar-image.ts @@ -11,6 +11,11 @@ export async function toAvatarBlob( { size = 512, mirror = false }: { size?: number; mirror?: boolean } = {}, ): Promise { const side = Math.min(source.width, source.height); + if (side === 0) { + // A not-yet-decoded