Author SHA1 Message Date
Arjun Patel 6ce7cc8651 fix syntax errors 2026-06-11 15:27:27 -07:00
Arjun Patel a25fb83a2a fix invalid migration 2026-06-11 15:25:13 -07:00
Arjun Patel a45beb9701 remove unused component 2026-06-11 15:24:15 -07:00
Arjun Patel 6f8349c62f bug in order 2026-06-11 15:20:19 -07:00
Arjun PatelGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
6bf2c9983a Update js/desktop/src/lib/avatar-image.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:16:13 -07:00
Arjun PatelGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
ffac812273 Update go/internal/handler/handler.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:14:51 -07:00
Arjun Patel fa9d54aab5 fixes 2026-06-11 15:03:33 -07:00
Arjun Patel c4e6cfa29b implement client side avatar upload and handling 2026-06-11 14:49:48 -07:00
Arjun Patel 71fe83bd0f typo 2026-06-11 13:57:08 -07:00
Arjun Patel 81f376cf62 add avatar endpoints 2026-06-11 13:55:09 -07:00
Arjun Patel 78c5151f3b implement avatar backend functionality 2026-06-11 13:47:34 -07:00
Arjun PatelandGitHub 66d53497c5 update claude.md (#269) 2026-06-11 10:58:36 -07:00
26 changed files with 746 additions and 262 deletions
+7 -6
View File
@@ -1,20 +1,24 @@
# Project Rules # Project Rules
## Architecture ## Architecture
- Electron typescript/react app is in `js/` folder - Electron typescript/react app is in `js/desktop` folder
- Orion is the api server which lives in the `go/` folder - Mobile react native expo app is in `js/mobile`
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
- `cpp/` points to our prototype of a C++ Qt widgets client - `cpp/` points to our prototype of a C++ Qt widgets client
Whenever implementing anything, make sure to take into account best practices without over-engineering. Whenever implementing anything, make sure to take into account best practices without over-engineering.
## Electron App ## Electron App
### Quality ### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices. We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
Another tradeoff we make is simpler, maintainable code over clever behavior. Another tradeoff we make is simpler, maintainable code over clever behavior.
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs. As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
### Comments
Don't add non-essential comments everywhere. Clean them up if so.
### Package Manager ### Package Manager
- Use **yarn** (not npm) for all dependency management - Use **yarn** (not npm) for all dependency management
@@ -25,6 +29,3 @@ Whenever possible, we should use the design system components. If we need to add
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through. When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
IF you find that the API is poorly designed, please suggest changes to improve the client experience. IF you find that the API is poorly designed, please suggest changes to improve the client experience.
## Verification
Check using `yarn compile` which lives in the package.json as a script.
+6 -1
View File
@@ -147,6 +147,11 @@ func main() {
// Settings // Settings
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) 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) // Push notification tokens (per-device)
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken)) mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
@@ -174,7 +179,7 @@ func main() {
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation)) mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
// Particles // Particles
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
// Link metadata // Link metadata
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata)) mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
+81 -2
View File
@@ -73,6 +73,7 @@ type Human struct {
Email string `json:"email"` Email string `json:"email"`
EmailPrefix string `json:"email_prefix"` EmailPrefix string `json:"email_prefix"`
EmailNotificationsEnabled bool `json:"email_notifications_enabled"` EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
AvatarObjectID *string `json:"avatar_object_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -334,6 +335,83 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 // Network Handlers
// ============================================================================ // ============================================================================
@@ -717,8 +795,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// DownloadParticleMedia returns a fresh signed URL for media/file particles. // GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context()) _, ok := middleware.EmailFromContext(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -1007,6 +1085,7 @@ func humanToDTO(h *human.Human) Human {
Email: h.Email, Email: h.Email,
EmailPrefix: h.EmailPrefix, EmailPrefix: h.EmailPrefix,
EmailNotificationsEnabled: h.EmailNotificationsEnabled, EmailNotificationsEnabled: h.EmailNotificationsEnabled,
AvatarObjectID: h.AvatarObjectID,
CreatedAt: h.CreatedAt, CreatedAt: h.CreatedAt,
} }
} }
+1
View File
@@ -7,6 +7,7 @@ type Human struct {
Email string Email string
EmailPrefix string EmailPrefix string
EmailNotificationsEnabled bool EmailNotificationsEnabled bool
AvatarObjectID *string
LastEmailNotificationSentAt *time.Time LastEmailNotificationSentAt *time.Time
CreatedAt time.Time CreatedAt time.Time
} }
+21 -6
View File
@@ -37,6 +37,7 @@ type repository interface {
listAll(ctx context.Context) ([]*Human, error) listAll(ctx context.Context) ([]*Human, error)
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
updateAvatarObjectID(ctx context.Context, id string, objectID *string) error
} }
type repositoryImpl struct { type repositoryImpl struct {
@@ -50,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository {
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) { func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
var h Human var h Human
err := r.pool.QueryRow(ctx, 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, 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound 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) { func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
var h Human var h Human
err := r.pool.QueryRow(ctx, 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, 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound 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) { func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
rows, err := r.pool.Query(ctx, 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 { if err != nil {
return nil, err return nil, err
@@ -123,7 +124,7 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
var humans []*Human var humans []*Human
for rows.Next() { for rows.Next() {
var h Human 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 return nil, err
} }
h.EmailPrefix = emailPrefix(h.Email) h.EmailPrefix = emailPrefix(h.Email)
@@ -159,3 +160,17 @@ func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context,
} }
return nil 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
}
+25 -1
View File
@@ -11,7 +11,10 @@ import (
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go //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 { type Service interface {
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
@@ -22,6 +25,8 @@ type Service interface {
ListAll(ctx context.Context) ([]*Human, error) ListAll(ctx context.Context) ([]*Human, error)
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) 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 { type serviceImpl struct {
@@ -88,3 +93,22 @@ func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id
} }
return err 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
}
+15
View File
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID) assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
assert.Equal(t, "[email protected]", anotherHuman.Email) assert.Equal(t, "[email protected]", anotherHuman.Email)
assert.Equal(t, "another", anotherHuman.EmailPrefix) 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)
} }
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE humans
DROP COLUMN IF EXISTS avatar_object_id;
COMMIT;
+6
View File
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE humans
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
COMMIT;
+34 -8
View File
@@ -42,16 +42,12 @@ class ApiClient {
this.config = config; this.config = config;
} }
private async fetch( private async send(
method: string, method: string,
path: string, path: string,
body?: unknown, init: { headers?: Record<string, string>; body?: BodyInit } = {},
): Promise<Response> { ): Promise<Response> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = { ...init.headers };
if (body) {
headers['Content-Type'] = 'application/json';
}
const token = this.config.getToken(); const token = this.config.getToken();
if (token) { if (token) {
@@ -61,7 +57,7 @@ class ApiClient {
const response = await fetch(`${this.config.baseUrl}${path}`, { const response = await fetch(`${this.config.baseUrl}${path}`, {
method, method,
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: init.body,
}); });
if (response.status === 401) { if (response.status === 401) {
@@ -77,6 +73,17 @@ class ApiClient {
return response; return response;
} }
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<Response> {
return this.send(method, path, {
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
}
private async request<T>( private async request<T>(
schema: z.ZodType<T>, schema: z.ZodType<T>,
method: string, method: string,
@@ -137,6 +144,25 @@ class ApiClient {
await this.requestVoid('PATCH', '/humans/me/settings', data); await this.requestVoid('PATCH', '/humans/me/settings', data);
} }
// --- Avatar ---
async updateAvatar(blob: Blob): Promise<void> {
await this.send('PUT', '/humans/me/avatar', {
headers: { 'Content-Type': blob.type || 'image/jpeg' },
body: blob,
});
}
async deleteAvatar(): Promise<void> {
await this.requestVoid('DELETE', '/humans/me/avatar');
}
async getAvatarDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
const data = await response.json();
return data.url;
}
// --- Depot --- // --- Depot ---
async prepareUpload(data: PrepareUploadRequest) { async prepareUpload(data: PrepareUploadRequest) {
+1
View File
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
email: z.string().email(), email: z.string().email(),
email_prefix: z.string(), email_prefix: z.string(),
email_notifications_enabled: z.boolean(), email_notifications_enabled: z.boolean(),
avatar_object_id: z.string().nullable().optional(),
}); });
export type Human = z.infer<typeof HumanSchema>; export type Human = z.infer<typeof HumanSchema>;
@@ -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<typeof Avatar>,
'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
* <Avatar> usages throughout the app.
*/
export function HumanAvatar({
avatarObjectId,
initials,
fallbackClassName,
...props
}: HumanAvatarProps) {
const url = useAvatarUrl(avatarObjectId);
return (
<Avatar {...props}>
{url && <AvatarImage src={url} alt={initials} />}
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
</Avatar>
);
}
+6 -5
View File
@@ -11,6 +11,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -45,11 +46,11 @@ function MemberRow({
return ( return (
<div className="flex w-full items-center gap-3 px-4 py-3"> <div className="flex w-full items-center gap-3 px-4 py-3">
<Avatar> <HumanAvatar
<AvatarFallback className="bg-primary/10 text-primary font-medium"> avatarObjectId={human.avatar_object_id}
{initials} initials={initials}
</AvatarFallback> fallbackClassName="bg-primary/10 text-primary font-medium"
</Avatar> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{human.email_prefix}</p> <p className="truncate text-sm font-medium">{human.email_prefix}</p>
<Muted className="text-xs">{human.email}</Muted> <Muted className="text-xs">{human.email}</Muted>
@@ -26,7 +26,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { particlePath } from '@/lib/particle-path'; import { particlePath } from '@/lib/particle-path';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
import { RelativeTimestamp } from '@/components/relative-timestamp'; 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 { Separator } from '@/components/ui/separator';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Small } from '@/components/ui/typography'; import { Small } from '@/components/ui/typography';
@@ -150,7 +150,7 @@ const StreamRow = memo(function StreamRow({
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:')); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { const avatar = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
@@ -158,7 +158,12 @@ const StreamRow = memo(function StreamRow({
if (otherEntry) { if (otherEntry) {
const otherId = otherEntry.replace('human:', ''); const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId); 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( const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id, (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, isDM,
particle.visible_to, particle.visible_to,
@@ -242,11 +255,12 @@ const StreamRow = memo(function StreamRow({
{videoThumbObjectId ? ( {videoThumbObjectId ? (
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} /> <VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
) : ( ) : (
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}> <HumanAvatar
<AvatarFallback className="bg-primary/10 text-primary font-medium"> className={cn(isUnseen && 'ring-2 ring-primary')}
{initials} avatarObjectId={avatar.avatarObjectId}
</AvatarFallback> initials={avatar.initials}
</Avatar> fallbackClassName="bg-primary/10 text-primary font-medium"
/>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -1,4 +1,4 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { HumanAvatar } from '@/components/human-avatar';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -161,18 +161,16 @@ function SegmentPresenceAvatars({
{visible.map((human) => ( {visible.map((human) => (
<Tooltip key={human.humanId}> <Tooltip key={human.humanId}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Avatar <HumanAvatar
size="xs" size="xs"
className={ className={
onlineHumanIds?.has(human.humanId) onlineHumanIds?.has(human.humanId)
? 'ring-2 ring-green-500' ? 'ring-2 ring-green-500'
: 'ring-1 ring-black/50' : 'ring-1 ring-black/50'
} }
> avatarObjectId={human.avatarObjectId}
<AvatarFallback> initials={human.emailPrefix.slice(0, 2).toUpperCase()}
{human.emailPrefix.slice(0, 2).toUpperCase()} />
</AvatarFallback>
</Avatar>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="text-xs"> <TooltipContent side="top" className="text-xs">
{human.email} {human.email}
@@ -5,7 +5,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } 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 { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
@@ -113,11 +113,13 @@ export function ReactionBar({
: 'bg-black/40 hover:bg-black/50', : 'bg-black/40 hover:bg-black/50',
)} )}
> >
<Avatar size="xs" className="shrink-0"> <HumanAvatar
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white"> size="xs"
{firstReactor.initials} className="shrink-0"
</AvatarFallback> avatarObjectId={firstReactor.avatarObjectId}
</Avatar> initials={firstReactor.initials}
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
/>
<span className="truncate text-white/90">{text}</span> <span className="truncate text-white/90">{text}</span>
{reactors.length > 1 && ( {reactors.length > 1 && (
<span className="shrink-0 text-white/60"> <span className="shrink-0 text-white/60">
@@ -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<HTMLDivElement, StreamCardProps>(
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 (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
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 */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
{hasActiveHuddle && (
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
)}
{shortcutKey && (
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
{shortcutKey}
</kbd>
)}
{latestChild ? (
<ParticlePreview particle={latestChild} />
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">
No messages yet
</p>
</div>
)}
{/* Transcript overlay for media with transcripts */}
{transcript && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
<p className="line-clamp-2 text-md leading-snug text-white/90">
{transcript}
</p>
</div>
)}
</div>
{/* Info bar */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Avatar
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
>
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
<Small
className={cn(
'min-w-0 truncate',
isUnseen
? 'font-semibold text-foreground'
: 'font-medium text-muted-foreground',
)}
>
{particle.properties.name}
</Small>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">
{huddleCount}
</span>
</span>
)}
{latestChild && (
<Small
className={cn(
'text-[10px]',
isUnseen ? 'text-primary' : 'text-muted-foreground',
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
</div>
</div>
);
},
);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react'; 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 { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint'; import { KeyHint } from '@/components/key-hint';
import { import {
@@ -170,11 +170,12 @@ export function StreamMembersOverlay({
key={id} key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70" className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
> >
<Avatar size="sm"> <HumanAvatar
<AvatarFallback className="text-[10px]"> size="sm"
{display.initials} avatarObjectId={display.avatarObjectId}
</AvatarFallback> initials={display.initials}
</Avatar> fallbackClassName="text-[10px]"
/>
<span <span
className={cn( className={cn(
'flex-1 truncate', 'flex-1 truncate',
@@ -225,11 +226,12 @@ export function StreamMembersOverlay({
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5', 'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
)} )}
> >
<Avatar size="sm"> <HumanAvatar
<AvatarFallback className="text-[10px]"> size="sm"
{getInitials(human.email)} avatarObjectId={human.avatar_object_id}
</AvatarFallback> initials={getInitials(human.email)}
</Avatar> fallbackClassName="text-[10px]"
/>
<span className="flex-1 truncate"> <span className="flex-1 truncate">
{human.email_prefix} {human.email_prefix}
</span> </span>
@@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types'; import { isParticleDeleted, type Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; 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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -147,11 +148,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
return ( return (
<Tooltip key={humanId}> <Tooltip key={humanId}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Avatar size="sm"> <HumanAvatar
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200"> size="sm"
{display.initials} avatarObjectId={display.avatarObjectId}
</AvatarFallback> initials={display.initials}
</Avatar> fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent> <TooltipContent>{display.email}</TooltipContent>
</Tooltip> </Tooltip>
@@ -313,11 +315,13 @@ function MembersIndicator({
<> <>
<AvatarGroup> <AvatarGroup>
{shownMembers.map((human) => ( {shownMembers.map((human) => (
<Avatar key={human.id} size="sm"> <HumanAvatar
<AvatarFallback className="text-[8px]"> key={human.id}
{resolveHumanDisplay(human.id, humans).initials} size="sm"
</AvatarFallback> avatarObjectId={human.avatar_object_id}
</Avatar> initials={resolveHumanDisplay(human.id, humans).initials}
fallbackClassName="text-[8px]"
/>
))} ))}
</AvatarGroup> </AvatarGroup>
{overflow > 0 && ( {overflow > 0 && (
@@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({
return ( return (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}> <HumanAvatar
<AvatarFallback>{display.initials}</AvatarFallback> size="sm"
</Avatar> className={isOnline ? 'ring-2 ring-green-500' : ''}
avatarObjectId={display.avatarObjectId}
initials={display.initials}
/>
{display.displayName} - <RelativeTimestamp date={particle.created_at} /> {display.displayName} - <RelativeTimestamp date={particle.created_at} />
</span> </span>
); );
+22 -6
View File
@@ -11,8 +11,10 @@ import {
FileText, FileText,
Volume2, Volume2,
ArrowLeft, ArrowLeft,
Camera,
} from 'lucide-react'; } 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 { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { WindowControls } from '@/components/window-controls'; import { WindowControls } from '@/components/window-controls';
@@ -88,6 +90,7 @@ export default function SettingsPage() {
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled); const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled); const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
const [version, setVersion] = useState<string>(); const [version, setVersion] = useState<string>();
const [avatarOpen, setAvatarOpen] = useState(false);
useEffect(() => { useEffect(() => {
platform.app.getVersion().then(setVersion); platform.app.getVersion().then(setVersion);
@@ -135,17 +138,30 @@ export default function SettingsPage() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{/* Profile header */} {/* Profile header */}
<div className="flex items-center gap-3 px-4 py-5"> <div className="flex items-center gap-3 px-4 py-5">
<Avatar size="lg"> <button
<AvatarFallback className="bg-primary/10 text-primary font-medium"> type="button"
{initials} onClick={() => setAvatarOpen(true)}
</AvatarFallback> className="group relative rounded-full"
</Avatar> aria-label="Change profile picture"
>
<HumanAvatar
size="lg"
avatarObjectId={user?.avatar_object_id}
initials={initials}
fallbackClassName="bg-primary/10 text-primary font-medium"
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Camera className="size-4 text-white" />
</span>
</button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user?.email_prefix}</p> <p className="truncate text-sm font-medium">{user?.email_prefix}</p>
<Muted className="text-xs">{user?.email}</Muted> <Muted className="text-xs">{user?.email}</Muted>
</div> </div>
</div> </div>
<AvatarEditDialog open={avatarOpen} onOpenChange={setAvatarOpen} />
<Separator /> <Separator />
<SettingsGroup title="Notifications"> <SettingsGroup title="Notifications">
@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Profile picture</DialogTitle>
<DialogDescription>
Upload an image or take one with your camera.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-1">
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
{previewUrl ? (
<img src={previewUrl} alt="" className="size-full object-cover" />
) : (
<span className="text-muted-foreground text-2xl font-medium">
{initials}
</span>
)}
</div>
<Tabs
value={tab}
onValueChange={(v) => setTab(v as 'upload' | 'camera')}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="upload">
<Upload />
Upload
</TabsTrigger>
<TabsTrigger value="camera">
<Camera />
Take photo
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="pt-3">
<div
{...dropZoneProps}
className={cn(
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-5 text-center transition-colors',
isDragging && 'border-primary bg-primary/5',
)}
>
<Muted className="text-xs">Drag an image here, or</Muted>
<Button variant="outline" size="sm" onClick={openFilePicker}>
Choose image
</Button>
</div>
</TabsContent>
<TabsContent value="camera" className="pt-3">
<CameraCapture
active={open && tab === 'camera'}
onCapture={setPreparedFromBlob}
/>
</TabsContent>
</Tabs>
</div>
<DialogFooter>
{hasAvatar && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive sm:mr-auto"
onClick={handleRemove}
disabled={busy}
>
<Trash2 className="mr-1 size-3.5" />
Remove
</Button>
)}
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
{busy && <Loader2 className="mr-1 size-3.5 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CameraCapture({
active,
onCapture,
}: {
active: boolean;
onCapture: (blob: Blob) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-5 text-center">
<Muted className="text-xs">Couldn't access your camera.</Muted>
<Muted className="text-[11px]">{error}</Muted>
</div>
);
}
return (
<div className="flex flex-col items-center gap-3">
<div className="bg-muted size-36 overflow-hidden rounded-full border">
<video
ref={videoRef}
muted
autoPlay
playsInline
className="size-full -scale-x-100 object-cover"
/>
</div>
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
<Camera className="mr-1 size-3.5" />
Capture
</Button>
</div>
);
}
+20
View File
@@ -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;
}
@@ -5,6 +5,7 @@ export interface HumanPresence {
humanId: string; humanId: string;
email: string; email: string;
emailPrefix: string; emailPrefix: string;
avatarObjectId: string | null;
} }
/** /**
@@ -42,6 +43,7 @@ export function usePresencePositions(
humanId: human.id, humanId: human.id,
email: human.email, email: human.email,
emailPrefix: human.email_prefix, emailPrefix: human.email_prefix,
avatarObjectId: human.avatar_object_id ?? null,
}; };
if (existing) { if (existing) {
existing.push(presence); existing.push(presence);
+36
View File
@@ -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<Blob> {
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 <video> or a corrupt image yields a zero-size source;
// cropping it would silently produce a blank avatar, so fail instead.
throw new Error('Image source has zero dimensions');
}
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 });
}
+4
View File
@@ -13,6 +13,8 @@ export interface HumanDisplay {
email: string; email: string;
/** Initials for avatar fallback. */ /** Initials for avatar fallback. */
initials: string; 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, displayName: REMOVED_MEMBER_LABEL,
email: REMOVED_MEMBER_LABEL, email: REMOVED_MEMBER_LABEL,
initials: REMOVED_MEMBER_INITIALS, initials: REMOVED_MEMBER_INITIALS,
avatarObjectId: null,
}; };
} }
return { return {
@@ -39,5 +42,6 @@ export function resolveHumanDisplay(
displayName: human.email_prefix, displayName: human.email_prefix,
email: human.email, email: human.email,
initials: getInitials(human.email), initials: getInitials(human.email),
avatarObjectId: human.avatar_object_id ?? null,
}; };
} }
+6
View File
@@ -30,6 +30,7 @@ interface AuthState {
isSigningOut: boolean; isSigningOut: boolean;
error: string | null; error: string | null;
restoreSession: () => Promise<void>; restoreSession: () => Promise<void>;
refreshUser: () => Promise<void>;
requestCode: (email: string) => Promise<void>; requestCode: (email: string) => Promise<void>;
signIn: (email: string, code: string) => Promise<void>; signIn: (email: string, code: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
@@ -64,6 +65,11 @@ export const useAuthStore = create<AuthState>((set) => ({
} }
}, },
refreshUser: async () => {
const user = await apiClient.me();
set({ user });
},
requestCode: async (email: string) => { requestCode: async (email: string) => {
set({ isRequestingCode: true, error: null }); set({ isRequestingCode: true, error: null });
try { try {