Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d88dbb1b |
@@ -147,11 +147,6 @@ 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))
|
||||||
@@ -179,7 +174,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.GetObjectDownloadUrl))
|
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
||||||
|
|
||||||
// Link metadata
|
// Link metadata
|
||||||
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,83 +334,6 @@ 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -795,8 +717,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
|
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
|
||||||
func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DownloadParticleMedia(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)
|
||||||
@@ -1085,7 +1007,6 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ 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 {
|
||||||
@@ -51,9 +50,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, avatar_object_id FROM humans WHERE email = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
||||||
email,
|
email,
|
||||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -67,9 +66,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, avatar_object_id FROM humans WHERE id = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -114,7 +113,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, avatar_object_id FROM humans`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -124,7 +123,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, &h.AvatarObjectID); err != nil {
|
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.EmailPrefix = emailPrefix(h.Email)
|
h.EmailPrefix = emailPrefix(h.Email)
|
||||||
@@ -160,17 +159,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ 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 (
|
var ErrNotFound = errors.New("human not found")
|
||||||
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)
|
||||||
@@ -25,8 +22,6 @@ 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 {
|
||||||
@@ -93,22 +88,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,19 +56,4 @@ 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE humans
|
|
||||||
DROP COLUMN IF EXISTS avatar_object_id;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE humans
|
|
||||||
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -42,12 +42,16 @@ class ApiClient {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async send(
|
private async fetch(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
init: { headers?: Record<string, string>; body?: BodyInit } = {},
|
body?: unknown,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers: Record<string, string> = { ...init.headers };
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
const token = this.config.getToken();
|
const token = this.config.getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -57,7 +61,7 @@ class ApiClient {
|
|||||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: init.body,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -73,17 +77,6 @@ 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,
|
||||||
@@ -144,25 +137,6 @@ 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) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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>;
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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,7 +11,6 @@ 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';
|
||||||
@@ -46,11 +45,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">
|
||||||
<HumanAvatar
|
<Avatar>
|
||||||
avatarObjectId={human.avatar_object_id}
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
initials={initials}
|
{initials}
|
||||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
</AvatarFallback>
|
||||||
/>
|
</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 { HumanAvatar } from '@/components/human-avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/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 avatar = useMemo(() => {
|
const initials = 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,12 +158,7 @@ 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) {
|
if (otherHuman) return getInitials(otherHuman.email);
|
||||||
return {
|
|
||||||
initials: getInitials(otherHuman.email),
|
|
||||||
avatarObjectId: otherHuman.avatar_object_id ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,18 +166,10 @@ 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) {
|
if (creator) return getInitials(creator.email);
|
||||||
return {
|
|
||||||
initials: getInitials(creator.email),
|
|
||||||
avatarObjectId: creator.avatar_object_id ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||||
initials: particle.properties.name.slice(0, 2).toUpperCase(),
|
|
||||||
avatarObjectId: null,
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
isDM,
|
isDM,
|
||||||
particle.visible_to,
|
particle.visible_to,
|
||||||
@@ -255,12 +242,11 @@ const StreamRow = memo(function StreamRow({
|
|||||||
{videoThumbObjectId ? (
|
{videoThumbObjectId ? (
|
||||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||||
) : (
|
) : (
|
||||||
<HumanAvatar
|
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
||||||
className={cn(isUnseen && 'ring-2 ring-primary')}
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
avatarObjectId={avatar.avatarObjectId}
|
{initials}
|
||||||
initials={avatar.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">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HumanAvatar } from '@/components/human-avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -161,16 +161,18 @@ function SegmentPresenceAvatars({
|
|||||||
{visible.map((human) => (
|
{visible.map((human) => (
|
||||||
<Tooltip key={human.humanId}>
|
<Tooltip key={human.humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<HumanAvatar
|
<Avatar
|
||||||
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}
|
>
|
||||||
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
<AvatarFallback>
|
||||||
/>
|
{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 { HumanAvatar } from '@/components/human-avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/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,13 +113,11 @@ export function ReactionBar({
|
|||||||
: 'bg-black/40 hover:bg-black/50',
|
: 'bg-black/40 hover:bg-black/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<Avatar size="xs" className="shrink-0">
|
||||||
size="xs"
|
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
|
||||||
className="shrink-0"
|
{firstReactor.initials}
|
||||||
avatarObjectId={firstReactor.avatarObjectId}
|
</AvatarFallback>
|
||||||
initials={firstReactor.initials}
|
</Avatar>
|
||||||
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">
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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 { HumanAvatar } from '@/components/human-avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/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,12 +170,11 @@ 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"
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<Avatar size="sm">
|
||||||
size="sm"
|
<AvatarFallback className="text-[10px]">
|
||||||
avatarObjectId={display.avatarObjectId}
|
{display.initials}
|
||||||
initials={display.initials}
|
</AvatarFallback>
|
||||||
fallbackClassName="text-[10px]"
|
</Avatar>
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 truncate',
|
'flex-1 truncate',
|
||||||
@@ -226,12 +225,11 @@ 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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<Avatar size="sm">
|
||||||
size="sm"
|
<AvatarFallback className="text-[10px]">
|
||||||
avatarObjectId={human.avatar_object_id}
|
{getInitials(human.email)}
|
||||||
initials={getInitials(human.email)}
|
</AvatarFallback>
|
||||||
fallbackClassName="text-[10px]"
|
</Avatar>
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
{human.email_prefix}
|
{human.email_prefix}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ 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 { AvatarGroup } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||||
import { HumanAvatar } from '@/components/human-avatar';
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -148,12 +147,11 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
return (
|
return (
|
||||||
<Tooltip key={humanId}>
|
<Tooltip key={humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<HumanAvatar
|
<Avatar size="sm">
|
||||||
size="sm"
|
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||||||
avatarObjectId={display.avatarObjectId}
|
{display.initials}
|
||||||
initials={display.initials}
|
</AvatarFallback>
|
||||||
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
|
</Avatar>
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{display.email}</TooltipContent>
|
<TooltipContent>{display.email}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -315,13 +313,11 @@ function MembersIndicator({
|
|||||||
<>
|
<>
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{shownMembers.map((human) => (
|
{shownMembers.map((human) => (
|
||||||
<HumanAvatar
|
<Avatar key={human.id} size="sm">
|
||||||
key={human.id}
|
<AvatarFallback className="text-[8px]">
|
||||||
size="sm"
|
{resolveHumanDisplay(human.id, humans).initials}
|
||||||
avatarObjectId={human.avatar_object_id}
|
</AvatarFallback>
|
||||||
initials={resolveHumanDisplay(human.id, humans).initials}
|
</Avatar>
|
||||||
fallbackClassName="text-[8px]"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
@@ -359,12 +355,9 @@ function ParticleBreadcrumbContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<HumanAvatar
|
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
||||||
size="sm"
|
<AvatarFallback>{display.initials}</AvatarFallback>
|
||||||
className={isOnline ? 'ring-2 ring-green-500' : ''}
|
</Avatar>
|
||||||
avatarObjectId={display.avatarObjectId}
|
|
||||||
initials={display.initials}
|
|
||||||
/>
|
|
||||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Volume2,
|
Volume2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Camera,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { HumanAvatar } from '@/components/human-avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/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';
|
||||||
@@ -90,7 +88,6 @@ 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);
|
||||||
@@ -138,30 +135,17 @@ 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">
|
||||||
<button
|
<Avatar size="lg">
|
||||||
type="button"
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
onClick={() => setAvatarOpen(true)}
|
{initials}
|
||||||
className="group relative rounded-full"
|
</AvatarFallback>
|
||||||
aria-label="Change profile picture"
|
</Avatar>
|
||||||
>
|
|
||||||
<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">
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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,7 +5,6 @@ export interface HumanPresence {
|
|||||||
humanId: string;
|
humanId: string;
|
||||||
email: string;
|
email: string;
|
||||||
emailPrefix: string;
|
emailPrefix: string;
|
||||||
avatarObjectId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +42,6 @@ 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);
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 });
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +32,6 @@ 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 {
|
||||||
@@ -42,6 +39,5 @@ 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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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>;
|
||||||
@@ -65,11 +64,6 @@ 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user