feat: avatars for humans #273
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
⚠️ Potential issue | 🟡 Minor | ⚡ Quick win Return 413 for oversized avatar uploads instead of 500. When Suggested fix📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_
**Return 413 for oversized avatar uploads instead of 500.**
When `http.MaxBytesReader` limit is exceeded, the handler currently falls into a generic 500. This should map to `http.StatusRequestEntityTooLarge`.
<details>
<summary>Suggested fix</summary>
```diff
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
}
```
</details>
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
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
}
```
</details>
<!-- suggestion_end -->
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@go/internal/handler/handler.go` around lines 381 - 391, The handler wraps the
request body with http.MaxBytesReader but treats any upload-size error from
h.depotSvc.CreateFromReader as a 500; detect the MaxBytesReader overflow and
return http.StatusRequestEntityTooLarge instead. Update the error handling after
calling h.depotSvc.CreateFromReader: if errors.Is(err, http.ErrBodyTooLarge) (or
if that sentinel isn't available, match the "request body too large" error
string) respond with http.Error(w, "request entity too large",
http.StatusRequestEntityTooLarge); otherwise keep the existing flog.Error and
500 behavior. Ensure you import the errors package if needed and reference the
r.Body wrapping and h.depotSvc.CreateFromReader call in your change.
```
</details>
<!-- fingerprinting:phantom:poseidon:hawk -->
<!-- cr-comment:v1:6fffad183715aaf0f26ca668 -->
<!-- This is an auto-generated comment by CodeRabbit -->
✅ Addressed in commits ffac812 to 6bf2c99
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type Human struct {
|
||||
Email string
|
||||
EmailPrefix string
|
||||
EmailNotificationsEnabled bool
|
||||
AvatarObjectID *string
|
||||
LastEmailNotificationSentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
|
||||
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
||||
assert.Equal(t, "[email protected]", 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
⚠️ Potential issue | 🔴 Critical | ⚡ Quick win Fix invalid SQL in rollback migration. Line 4 has a trailing comma, which makes the down migration fail to execute. Suggested fix📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ Potential issue_ | _🔴 Critical_ | _⚡ Quick win_
**Fix invalid SQL in rollback migration.**
Line 4 has a trailing comma, which makes the down migration fail to execute.
<details>
<summary>Suggested fix</summary>
```diff
ALTER TABLE humans
- DROP COLUMN IF EXISTS avatar_object_id,
+ DROP COLUMN IF EXISTS avatar_object_id;
```
</details>
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
ALTER TABLE humans
DROP COLUMN IF EXISTS avatar_object_id;
```
</details>
<!-- suggestion_end -->
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@go/migrations/000017_human_avatar.down.sql` at line 4, The rollback migration
SQL in 000017_human_avatar.down.sql contains a trailing comma after the DROP
COLUMN list ("DROP COLUMN IF EXISTS avatar_object_id,") which makes the
statement invalid; remove the trailing comma so the DROP COLUMN clause is a
properly terminated SQL statement (ensure the DROP COLUMN list ends without a
comma and the statement ends with the appropriate semicolon or end-of-statement
token).
```
</details>
<!-- fingerprinting:phantom:poseidon:hawk -->
<!-- cr-comment:v1:6d0227aabf7772fa5ddadda5 -->
<!-- This is an auto-generated comment by CodeRabbit -->
✅ Addressed in commits a45beb9 to a25fb83
|
||||
|
||||
ALTER TABLE humans
|
||||
DROP COLUMN IF EXISTS avatar_object_id;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE humans
|
||||
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -42,16 +42,12 @@ class ApiClient {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
private async send(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
init: { headers?: Record<string, string>; body?: BodyInit } = {},
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const headers: Record<string, string> = { ...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<Response> {
|
||||
return this.send(method, path, {
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
schema: z.ZodType<T>,
|
||||
method: string,
|
||||
@@ -137,6 +144,25 @@ class ApiClient {
|
||||
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 ---
|
||||
|
||||
async prepareUpload(data: PrepareUploadRequest) {
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
avatarObjectId={human.avatar_object_id}
|
||||
initials={initials}
|
||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
|
||||
<Muted className="text-xs">{human.email}</Muted>
|
||||
|
||||
@@ -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 ? (
|
||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||
) : (
|
||||
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
className={cn(isUnseen && 'ring-2 ring-primary')}
|
||||
avatarObjectId={avatar.avatarObjectId}
|
||||
initials={avatar.initials}
|
||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -161,18 +161,16 @@ function SegmentPresenceAvatars({
|
||||
{visible.map((human) => (
|
||||
<Tooltip key={human.humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar
|
||||
<HumanAvatar
|
||||
size="xs"
|
||||
className={
|
||||
onlineHumanIds?.has(human.humanId)
|
||||
? 'ring-2 ring-green-500'
|
||||
: 'ring-1 ring-black/50'
|
||||
}
|
||||
>
|
||||
<AvatarFallback>
|
||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
avatarObjectId={human.avatarObjectId}
|
||||
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{human.email}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
|
||||
{firstReactor.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
avatarObjectId={firstReactor.avatarObjectId}
|
||||
initials={firstReactor.initials}
|
||||
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
|
||||
/>
|
||||
<span className="truncate text-white/90">{text}</span>
|
||||
{reactors.length > 1 && (
|
||||
<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 { 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"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{display.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
initials={display.initials}
|
||||
fallbackClassName="text-[10px]"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(human.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
avatarObjectId={human.avatar_object_id}
|
||||
initials={getInitials(human.email)}
|
||||
fallbackClassName="text-[10px]"
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{human.email_prefix}
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip key={humanId}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||||
{display.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
initials={display.initials}
|
||||
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{display.email}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -313,11 +315,13 @@ function MembersIndicator({
|
||||
<>
|
||||
<AvatarGroup>
|
||||
{shownMembers.map((human) => (
|
||||
<Avatar key={human.id} size="sm">
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{resolveHumanDisplay(human.id, humans).initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
key={human.id}
|
||||
size="sm"
|
||||
avatarObjectId={human.avatar_object_id}
|
||||
initials={resolveHumanDisplay(human.id, humans).initials}
|
||||
fallbackClassName="text-[8px]"
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{overflow > 0 && (
|
||||
@@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
||||
<AvatarFallback>{display.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
className={isOnline ? 'ring-2 ring-green-500' : ''}
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
initials={display.initials}
|
||||
/>
|
||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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<string>();
|
||||
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
platform.app.getVersion().then(setVersion);
|
||||
@@ -135,17 +138,30 @@ export default function SettingsPage() {
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-3 px-4 py-5">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAvatarOpen(true)}
|
||||
className="group relative rounded-full"
|
||||
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">
|
||||
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
|
||||
<Muted className="text-xs">{user?.email}</Muted>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AvatarEditDialog open={avatarOpen} onOpenChange={setAvatarOpen} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
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);
|
||||
|
||||
@@ -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> {
|
||||
|
⚠️ Potential issue | 🟡 Minor | ⚡ Quick win Validate Line 22 depends on Suggested fix📝 Committable suggestion
🤖 Prompt for AI Agents_⚠️ Potential issue_ | _🟡 Minor_ | _⚡ Quick win_
**Validate `size` as a positive integer before creating the canvas.**
Line 22 depends on `size`; invalid values can yield broken avatar output or throw at runtime.
<details>
<summary>Suggested fix</summary>
```diff
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) {
```
</details>
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
{ 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');
}
```
</details>
<!-- suggestion_end -->
<details>
<summary>🤖 Prompt for AI Agents</summary>
```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@js/desktop/src/lib/avatar-image.ts` around lines 11 - 12, Validate and
normalize the incoming size parameter at the start of the avatar generation
function (the function with signature "{ size = 512, mirror = false }: { size?:
number; mirror?: boolean } = {}, ): Promise<Blob>"). Ensure size is a positive
integer before using it to create the canvas: check Number.isInteger(size) &&
size > 0 (or coerce via Math.floor and then verify >0), and if invalid either
throw a clear error or fall back to a safe default (e.g., 512); then use that
validated/normalized value for the canvas creation to prevent runtime errors or
broken avatars.
```
</details>
<!-- fingerprinting:phantom:poseidon:hawk -->
<!-- cr-comment:v1:3412b6e39458ff9b8c5d8ef9 -->
<!-- This is an auto-generated comment by CodeRabbit -->
✅ Addressed in commits ffac812 to 6bf2c99
|
||||
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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AuthState {
|
||||
isSigningOut: boolean;
|
||||
error: string | null;
|
||||
restoreSession: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
requestCode: (email: string) => Promise<void>;
|
||||
signIn: (email: string, code: string) => 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) => {
|
||||
set({ isRequestingCode: true, error: null });
|
||||
try {
|
||||
|
||||
⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift
Avatar blob lifecycle is inconsistent and leaks objects.
Line 345 clears
avatar_object_idbefore reading prior state, so the old avatar object ID is lost and never deleted.Also, if Line 383 upload succeeds but Line 394 DB update fails, the newly uploaded object is orphaned.
Suggested lifecycle shape
Also applies to: 383-399
🤖 Prompt for AI Agents
⚠️ Potential issue | 🔴 Critical | ⚡ Quick win
Remove the unmatched
}inUpdateAvatar.Line 398 closes the function early, so Line 400 starts with a bare assignment and the file no longer parses. That matches the CI failure exactly.
Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Pipeline failures