diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 09ec9fd..5587ac5 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -147,6 +147,11 @@ 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, given objectId + mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl)) // Push notification tokens (per-device) mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken)) @@ -174,7 +179,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..99e315a 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,83 @@ 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 + } + + human, err := h.humanSvc.GetByID(r.Context(), humanId) + if err != nil { + flog.Error("failed to get human by id", "error", err, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + 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 + } + + 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 { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, "avatar file too large", http.StatusRequestEntityTooLarge) + return + } + 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) + + // best effort + err = h.depotSvc.Delete(r.Context(), object.ID) + if err != nil { + flog.Error("best-effort delete of object failed", "error", err) + } + + return + } + + w.WriteHeader(http.StatusNoContent) +} + // ============================================================================ // Network Handlers // ============================================================================ @@ -717,8 +795,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 +1085,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..7720db9 --- /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; 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..865efee --- /dev/null +++ b/js/desktop/src/components/human-avatar.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAvatarUrl } from '@/hooks/use-avatar-url'; + +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. */ + 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 deleted file mode 100644 index d284f12..0000000 --- a/js/desktop/src/features/particles/stream-card.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { forwardRef, useMemo } from 'react'; -import { Headphones } from 'lucide-react'; -import { cn, getInitials } from '@/lib/utils'; -import { useLiveLatestChild } from '@/hooks/use-particle'; -import { useAuthStore } from '@/stores/auth-store'; -import { particlePath } from '@/lib/particle-path'; -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 { RelativeTimestamp } from '@/components/relative-timestamp'; -import { Small } from '@/components/ui/typography'; - -interface StreamCardProps { - particle: Particle & { type: 'stream'; properties: StreamProperties }; - networkId: string; - onClick: () => void; - isSelected?: boolean; - shortcutKey?: number; -} - -export const StreamCard = forwardRef( - function StreamCard( - { particle, networkId, onClick, isSelected, shortcutKey }, - ref, - ) { - const streamPath = particlePath(networkId, [particle.id]); - const { latestChild } = useLiveLatestChild(streamPath); - const userId = useAuthStore((s) => s.user?.id) ?? ''; - const network = useNetwork(networkId); - - useStreamAutoplay(latestChild, particle, networkId, network ?? undefined); - - const hasActiveHuddle = - particle.huddle_active_participants && - particle.huddle_active_participants.length > 0; - const huddleCount = particle.huddle_active_participants?.length ?? 0; - - const isDM = - particle.visible_to.length === 2 && - particle.visible_to.every((v) => v.startsWith('human:')); - - const initials = useMemo(() => { - if (isDM) { - const otherEntry = particle.visible_to.find( - (v) => v !== `human:${userId}`, - ); - if (otherEntry) { - const otherId = otherEntry.replace('human:', ''); - const otherHuman = network?.humans?.find((h) => h.id === otherId); - if (otherHuman) return getInitials(otherHuman.email); - } - } - - if (latestChild) { - const creator = network?.humans?.find( - (h) => h.id === latestChild.created_by_human_id, - ); - if (creator) return getInitials(creator.email); - } - - return particle.properties.name.slice(0, 2).toUpperCase(); - }, [ - isDM, - particle.visible_to, - particle.properties.name, - userId, - latestChild, - network, - ]); - - const isUnseen = useMemo(() => { - if (!latestChild) return false; - const latestChildTimestamp = latestChild.created_at.getTime(); - const userPlaybackPosition = - particle.playback_markers?.[userId]?.getTime() ?? 0; - return latestChildTimestamp > userPlaybackPosition; - }, [latestChild, particle.playback_markers, userId]); - - // For media particles with a transcript, show it as an overlay on the preview - const transcript = - latestChild?.type === 'media' - ? latestChild.properties.transcript?.transcript - : undefined; - - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') onClick(); - }} - className={cn( - 'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20', - isUnseen && 'ring-2 ring-primary', - isSelected && 'ring-2 ring-ring', - hasActiveHuddle && 'ring-2 ring-red-500/70', - )} - > - {/* Preview area */} -
- {hasActiveHuddle && ( -
- )} - {shortcutKey && ( - - {shortcutKey} - - )} - {latestChild ? ( - - ) : ( -
-

- No messages yet -

-
- )} - - {/* Transcript overlay for media with transcripts */} - {transcript && ( -
-

- {transcript} -

-
- )} -
- - {/* Info bar */} -
- - - {initials} - - - - {particle.properties.name} - -
- {hasActiveHuddle && ( - - - - {huddleCount} - - - )} - {latestChild && ( - - - - )} - {isUnseen && ( - - )} -
-
-
- ); - }, -); diff --git a/js/desktop/src/features/particles/stream-members-overlay.tsx b/js/desktop/src/features/particles/stream-members-overlay.tsx index 04578fe..5be51f3 100644 --- a/js/desktop/src/features/particles/stream-members-overlay.tsx +++ b/js/desktop/src/features/particles/stream-members-overlay.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { X, UserPlus, Globe, Users, Lock } from 'lucide-react'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { HumanAvatar } from '@/components/human-avatar'; import { ScrollArea } from '@/components/ui/scroll-area'; import { KeyHint } from '@/components/key-hint'; import { @@ -170,11 +170,12 @@ export function StreamMembersOverlay({ key={id} className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70" > - - - {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..14f23a6 --- /dev/null +++ b/js/desktop/src/features/settings/avatar-edit-dialog.tsx @@ -0,0 +1,340 @@ +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 { 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'; +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); + // Honor the camera the user picked in Audio & Video settings. `ideal` rather + // than `exact` so a since-unplugged device falls back to the default instead + // of throwing OverconstrainedError. + const savedCameraId = useMediaDevicesStore((s) => s.camera?.deviceId); + + useEffect(() => { + if (!active) return; + + let cancelled = false; + let acquired: MediaStream | null = null; + + const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } }; + if (savedCameraId) video.deviceId = { ideal: savedCameraId }; + + navigator.mediaDevices + .getUserMedia({ video, 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, savedCameraId]); + + useEffect(() => { + if (videoRef.current) videoRef.current.srcObject = stream; + }, [stream]); + + const handleCapture = async () => { + const video = videoRef.current; + // readyState < HAVE_CURRENT_DATA (or zero dimensions) means no frame has + // decoded yet — capturing now would grab a blank image. + if (!video || video.readyState < 2 || !video.videoWidth) { + toast.error('Camera is still starting — try again in a moment.'); + 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..6bc9b79 --- /dev/null +++ b/js/desktop/src/lib/avatar-image.ts @@ -0,0 +1,36 @@ +/** + * 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 { + if (!Number.isInteger(size) || size <= 0) { + throw new Error('Avatar size must be a positive integer'); + } + const side = Math.min(source.width, source.height); + if (side === 0) { + // A not-yet-decoded