12 Commits

Author SHA1 Message Date
Arjun Patel 6ce7cc8651 fix syntax errors 2026-06-11 15:27:27 -07:00
Arjun Patel a25fb83a2a fix invalid migration 2026-06-11 15:25:13 -07:00
Arjun Patel a45beb9701 remove unused component 2026-06-11 15:24:15 -07:00
Arjun Patel 6f8349c62f bug in order 2026-06-11 15:20:19 -07:00
Arjun Patel 6bf2c9983a Update js/desktop/src/lib/avatar-image.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:16:13 -07:00
Arjun Patel ffac812273 Update go/internal/handler/handler.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 15:14:51 -07:00
Arjun Patel fa9d54aab5 fixes 2026-06-11 15:03:33 -07:00
Arjun Patel c4e6cfa29b implement client side avatar upload and handling 2026-06-11 14:49:48 -07:00
Arjun Patel 71fe83bd0f typo 2026-06-11 13:57:08 -07:00
Arjun Patel 81f376cf62 add avatar endpoints 2026-06-11 13:55:09 -07:00
Arjun Patel 78c5151f3b implement avatar backend functionality 2026-06-11 13:47:34 -07:00
Arjun Patel 66d53497c5 update claude.md (#269) 2026-06-11 10:58:36 -07:00
26 changed files with 746 additions and 262 deletions
+7 -6
View File
@@ -1,20 +1,24 @@
# Project Rules
## Architecture
- Electron typescript/react app is in `js/` folder
- Orion is the api server which lives in the `go/` folder
- Electron typescript/react app is in `js/desktop` folder
- Mobile react native expo app is in `js/mobile`
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
- `cpp/` points to our prototype of a C++ Qt widgets client
Whenever implementing anything, make sure to take into account best practices without over-engineering.
## Electron App
### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices.
We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
Another tradeoff we make is simpler, maintainable code over clever behavior.
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
### Comments
Don't add non-essential comments everywhere. Clean them up if so.
### Package Manager
- Use **yarn** (not npm) for all dependency management
@@ -25,6 +29,3 @@ Whenever possible, we should use the design system components. If we need to add
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
## Verification
Check using `yarn compile` which lives in the package.json as a script.
+6 -1
View File
@@ -147,6 +147,11 @@ func main() {
// Settings
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))
+81 -2
View File
@@ -73,6 +73,7 @@ type Human struct {
Email string `json:"email"`
EmailPrefix string `json:"email_prefix"`
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
AvatarObjectID *string `json:"avatar_object_id"`
CreatedAt time.Time `json:"created_at"`
}
@@ -334,6 +335,83 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
human, err := h.humanSvc.GetByID(r.Context(), humanId)
if err != nil {
flog.Error("failed to get human by id", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
err = h.humanSvc.DeleteAvatar(r.Context(), humanId)
if err != nil {
flog.Error("failed to delete avatar from human", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if human.AvatarObjectID != nil {
err = h.depotSvc.Delete(r.Context(), utils.OptionalString(human.AvatarObjectID))
if err != nil {
flog.Error("failed to delete object", "error", err, "objectID", human.AvatarObjectID)
}
}
w.WriteHeader(http.StatusNoContent)
return
}
func (h *Handler) UpdateAvatar(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// 5MB limit = 5 * 1024 * 1024 bytes
const maxBodySize = 5 << 20
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
object, err := h.depotSvc.CreateFromReader(r.Context(), depot.CreateFromReaderInput{
Prefix: "avatars",
Name: fmt.Sprintf("%s-avatar", humanId),
ContentType: r.Header.Get("Content-Type"),
}, r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "avatar file too large", http.StatusRequestEntityTooLarge)
return
}
flog.Error("failed to upload avatar with depo", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
err = h.humanSvc.UpdateAvatar(r.Context(), humanId, object.ID)
if err != nil {
flog.Error("failed to update human avatar", "error", err, "humanId", humanId)
http.Error(w, "internal server error", http.StatusInternalServerError)
// best effort
err = h.depotSvc.Delete(r.Context(), object.ID)
if err != nil {
flog.Error("best-effort delete of object failed", "error", err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// Network Handlers
// ============================================================================
@@ -717,8 +795,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
// GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -1007,6 +1085,7 @@ func humanToDTO(h *human.Human) Human {
Email: h.Email,
EmailPrefix: h.EmailPrefix,
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
AvatarObjectID: h.AvatarObjectID,
CreatedAt: h.CreatedAt,
}
}
+1
View File
@@ -7,6 +7,7 @@ type Human struct {
Email string
EmailPrefix string
EmailNotificationsEnabled bool
AvatarObjectID *string
LastEmailNotificationSentAt *time.Time
CreatedAt time.Time
}
+21 -6
View File
@@ -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
}
+25 -1
View File
@@ -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
}
+15
View File
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
assert.Equal(t, "another@example.com", anotherHuman.Email)
assert.Equal(t, "another", anotherHuman.EmailPrefix)
// Test avatar handling
objectID := "obj_xxx"
err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID)
assert.NoError(t, err)
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
assert.NoError(t, err)
assert.Equal(t, objectID, *anotherHuman.AvatarObjectID)
err = svc.DeleteAvatar(ctx, anotherHuman.ID)
assert.NoError(t, err)
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
assert.NoError(t, err)
assert.Nil(t, anotherHuman.AvatarObjectID)
}
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE humans
DROP COLUMN IF EXISTS avatar_object_id;
COMMIT;
+6
View File
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE humans
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
COMMIT;
+34 -8
View File
@@ -42,16 +42,12 @@ class ApiClient {
this.config = config;
}
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) {
+1
View File
@@ -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>
);
}
+6 -5
View File
@@ -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>
);
+22 -6
View File
@@ -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>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useQuery, skipToken } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
/**
* Resolves an avatar object id to a signed download URL. Mirrors
* {@link import('./use-download-url').useDownloadUrl} — React Query handles
* caching and de-duping, so many avatars sharing an id make a single request.
*/
export function useAvatarUrl(
objectId: string | null | undefined,
): string | undefined {
const { data } = useQuery({
queryKey: ['avatar-url', objectId],
queryFn: objectId
? () => apiClient.getAvatarDownloadUrl(objectId)
: skipToken,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
return data;
}
@@ -5,6 +5,7 @@ export interface HumanPresence {
humanId: string;
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);
+36
View File
@@ -0,0 +1,36 @@
/**
* Center-crop an image source to a square and downscale it to a JPEG suitable
* for an avatar. Avatars never render larger than ~80px, so 512px is generous
* headroom while keeping the upload to a few tens of KB regardless of input.
*
* Pass `mirror` when capturing from a (mirrored) webcam preview so the saved
* image matches what the user saw.
*/
export async function toAvatarBlob(
source: ImageBitmap,
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
): Promise<Blob> {
if (!Number.isInteger(size) || size <= 0) {
throw new Error('Avatar size must be a positive integer');
}
const side = Math.min(source.width, source.height);
if (side === 0) {
// A not-yet-decoded <video> or a corrupt image yields a zero-size source;
// cropping it would silently produce a blank avatar, so fail instead.
throw new Error('Image source has zero dimensions');
}
const sx = (source.width - side) / 2;
const sy = (source.height - side) / 2;
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to acquire 2D canvas context');
if (mirror) {
ctx.translate(size, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size);
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 });
}
+4
View File
@@ -13,6 +13,8 @@ export interface HumanDisplay {
email: string;
/** 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,
};
}
+6
View File
@@ -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 {